test_internal_storage_probe_2856.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437
  1. """An eMMC dispatch is not proof the file is out of reach (#2856).
  2. ``print_storage`` reads the ``project_file`` URL: ``brtc://emmc/<name>`` says
  3. the printer put the sliced file on internal storage, and #2780 turned that into
  4. "skip the FTPS lookup entirely". On an H2D with a card in the slot that is
  5. wrong. The reporter's log has ``"url": "brtc://emmc/test.gcode.3mf"`` and, a
  6. second later, ``Downloaded: /cache/test.gcode.3mf`` -- every one of his prints
  7. back to 08-12, 19 MB included, until the skip landed and two days of archives
  8. came out as name-only fallbacks.
  9. So the URL earns a *bounded probe* rather than a skip. It names the exact file,
  10. which is one connection walking five directories instead of the sweep's ~110,
  11. and the printer answers the question instead of a guess about its model. These
  12. tests pin both halves: the probe runs and its hit is archived normally, and a
  13. miss still ends in #2780's cheap fallback with its reason intact.
  14. """
  15. import logging
  16. from pathlib import Path
  17. from unittest.mock import AsyncMock, MagicMock, patch
  18. import pytest
  19. from backend.app.main import (
  20. _active_prints,
  21. _expected_print_creators,
  22. _expected_print_registered_at,
  23. _expected_prints,
  24. _print_ams_mappings,
  25. _timelapse_baselines,
  26. )
  27. from backend.app.services.print_storage import (
  28. ftp_probe_paths,
  29. print_file_reachable_over_ftp,
  30. probe_filename_from_url,
  31. )
  32. pytestmark = pytest.mark.unit
  33. @pytest.fixture(autouse=True)
  34. def _clear_dicts():
  35. for d in (
  36. _expected_prints,
  37. _expected_print_registered_at,
  38. _expected_print_creators,
  39. _print_ams_mappings,
  40. _active_prints,
  41. _timelapse_baselines,
  42. ):
  43. d.clear()
  44. yield
  45. for d in (
  46. _expected_prints,
  47. _expected_print_registered_at,
  48. _expected_print_creators,
  49. _print_ams_mappings,
  50. _active_prints,
  51. _timelapse_baselines,
  52. ):
  53. d.clear()
  54. class TestProbeFilename:
  55. """What the probe asks for. The dispatch's name is the authoritative one --
  56. the sweep's guesses are built from ``subtask_name``, which is normalized,
  57. truncated and occasionally a plate behind."""
  58. def test_the_reported_case(self):
  59. assert probe_filename_from_url("brtc://emmc/test.gcode.3mf") == "test.gcode.3mf"
  60. def test_a_name_with_the_punctuation_users_actually_use(self):
  61. """Straight from the reporter's log -- ampersands, parentheses, plus."""
  62. url = "brtc://emmc/H2D_&_H2S_poop_chute+4_buckets_(no_magnets_&_glue).gcode.3mf"
  63. assert probe_filename_from_url(url) == "H2D_&_H2S_poop_chute+4_buckets_(no_magnets_&_glue).gcode.3mf"
  64. def test_a_non_ascii_name(self):
  65. assert probe_filename_from_url("brtc://emmc/小船.gcode.3mf") == "小船.gcode.3mf"
  66. def test_an_internal_file_path_keeps_only_the_name(self):
  67. """The model cache is not reachable at that path, but the same file may
  68. well be sitting in /cache under its bare name."""
  69. assert probe_filename_from_url("file:///userdata/model/history/Cube.gcode.3mf") == "Cube.gcode.3mf"
  70. @pytest.mark.parametrize(
  71. "url",
  72. [
  73. None,
  74. "",
  75. "Benchy.gcode.3mf", # no scheme -- not a dispatch URL at all
  76. "brtc://emmc/",
  77. "brtc://emmc/Benchy.gcode", # a gcode job has no 3MF at any path
  78. "brtc://emmc/.",
  79. "brtc://emmc/..",
  80. 12345,
  81. ],
  82. )
  83. def test_nothing_to_probe_with(self, url):
  84. assert probe_filename_from_url(url) is None
  85. @pytest.mark.parametrize(
  86. "url",
  87. [
  88. "brtc://emmc/..\\..\\evil.3mf", # backslash is a separator on the host
  89. "brtc://emmc/sub\\dir\\Cube.3mf",
  90. "brtc://emmc/Cube\n.3mf", # control characters
  91. "brtc://emmc/" + "C" * 256 + ".3mf",
  92. ],
  93. )
  94. def test_a_name_that_could_steer_a_path_is_refused_not_cleaned(self, url):
  95. """The name comes off the wire and becomes both a remote path and a
  96. local temp filename, so a name that could point at either somewhere
  97. else is declined outright -- the print falls back to the archive it
  98. would have got anyway."""
  99. assert probe_filename_from_url(url) is None
  100. class TestProbePaths:
  101. def test_root_first_then_cache(self):
  102. """Order is the sweep's own: root is where A1/P1 uploads land (#972),
  103. /cache is where the H2D keeps its copy (#2856)."""
  104. assert ftp_probe_paths("Cube.gcode.3mf")[:2] == ["/Cube.gcode.3mf", "/cache/Cube.gcode.3mf"]
  105. def test_one_filename_five_paths(self):
  106. assert len(ftp_probe_paths("Cube.gcode.3mf")) == 5
  107. class TestFindRemoteFile:
  108. """The lookup the connection diagnostic runs. It must not transfer the
  109. file -- the thing it is asking about can be tens of megabytes and the
  110. answer is a yes or a no."""
  111. @staticmethod
  112. def _client(listings):
  113. client = MagicMock()
  114. client.connect.return_value = True
  115. client.list_files.side_effect = lambda directory: [
  116. {"name": name, "is_directory": False} for name in listings.get(directory, [])
  117. ]
  118. return client
  119. @pytest.mark.asyncio
  120. async def test_finds_the_file_in_cache_and_stops_there(self):
  121. client = self._client({"/": ["other.3mf"], "/cache": ["test.gcode.3mf"]})
  122. with patch("backend.app.services.bambu_ftp.BambuFTPClient", return_value=client):
  123. from backend.app.services.bambu_ftp import find_remote_file_async
  124. found = await find_remote_file_async("1.2.3.4", "code", ftp_probe_paths("test.gcode.3mf"))
  125. assert found == "/cache/test.gcode.3mf"
  126. # One connection, and the directories after the hit are never listed.
  127. client.connect.assert_called_once()
  128. assert [call.args[0] for call in client.list_files.call_args_list] == ["/", "/cache"]
  129. client.download_to_file.assert_not_called()
  130. client.disconnect.assert_called_once()
  131. @pytest.mark.asyncio
  132. async def test_a_directory_is_listed_once_however_many_candidates_share_it(self):
  133. client = self._client({})
  134. with patch("backend.app.services.bambu_ftp.BambuFTPClient", return_value=client):
  135. from backend.app.services.bambu_ftp import find_remote_file_async
  136. found = await find_remote_file_async("1.2.3.4", "code", ["/a.3mf", "/b.3mf", "/cache/a.3mf"])
  137. assert found is None
  138. assert [call.args[0] for call in client.list_files.call_args_list] == ["/", "/cache"]
  139. @pytest.mark.asyncio
  140. async def test_a_directory_of_the_same_name_is_not_the_file(self):
  141. client = MagicMock()
  142. client.connect.return_value = True
  143. client.list_files.return_value = [{"name": "test.gcode.3mf", "is_directory": True}]
  144. with patch("backend.app.services.bambu_ftp.BambuFTPClient", return_value=client):
  145. from backend.app.services.bambu_ftp import find_remote_file_async
  146. assert await find_remote_file_async("1.2.3.4", "code", ftp_probe_paths("test.gcode.3mf")) is None
  147. @pytest.mark.asyncio
  148. async def test_a_refused_connection_is_not_an_answer(self):
  149. client = MagicMock()
  150. client.connect.return_value = False
  151. with patch("backend.app.services.bambu_ftp.BambuFTPClient", return_value=client):
  152. from backend.app.services.bambu_ftp import find_remote_file_async
  153. assert await find_remote_file_async("1.2.3.4", "code", ftp_probe_paths("test.gcode.3mf")) is None
  154. client.list_files.assert_not_called()
  155. class TestVerdictCarriesTheName:
  156. def test_an_internal_dispatch_carries_a_probe_name(self):
  157. state = MagicMock(current_project_url="brtc://emmc/test.gcode.3mf", sdcard=True, sdcard_reported=True)
  158. verdict = print_file_reachable_over_ftp(state)
  159. assert verdict.reachable is False
  160. assert verdict.reason == "internal_storage"
  161. assert verdict.probe_filename == "test.gcode.3mf"
  162. def test_an_external_dispatch_needs_no_probe(self):
  163. """It sweeps as it always did; a probe name here would only invite a
  164. caller to shorten a search that is already working."""
  165. state = MagicMock(current_project_url="ftp://test.gcode.3mf", sdcard=True, sdcard_reported=True)
  166. verdict = print_file_reachable_over_ftp(state)
  167. assert verdict.reachable is True
  168. assert verdict.probe_filename is None
  169. def test_an_empty_slot_has_nothing_to_probe(self):
  170. """No URL and no card: there is no name, and no storage to look on."""
  171. state = MagicMock(current_project_url=None, sdcard=False, sdcard_reported=True)
  172. verdict = print_file_reachable_over_ftp(state)
  173. assert verdict.reason == "no_external_storage"
  174. assert verdict.probe_filename is None
  175. def test_an_empty_slot_is_not_probed_even_with_a_name(self):
  176. """#2780's H2C ran for three weeks with the toggle on and nothing in
  177. the slot. There is no card for a copy to be on, so the name is not
  178. worth a connection -- the printer has already answered."""
  179. state = MagicMock(current_project_url="brtc://emmc/test.gcode.3mf", sdcard=False, sdcard_reported=True)
  180. verdict = print_file_reachable_over_ftp(state)
  181. assert verdict.reason == "internal_storage"
  182. assert verdict.probe_filename is None
  183. def test_a_printer_that_never_mentions_its_card_is_still_probed(self):
  184. """`sdcard` defaults to False, so acting on the default would drop the
  185. probe for every printer that simply does not publish the field."""
  186. state = MagicMock(current_project_url="brtc://emmc/test.gcode.3mf", sdcard=False, sdcard_reported=False)
  187. assert print_file_reachable_over_ftp(state).probe_filename == "test.gcode.3mf"
  188. def _printer():
  189. printer = MagicMock()
  190. printer.id = 1
  191. printer.auto_archive = True
  192. printer.external_camera_enabled = False
  193. printer.external_camera_url = None
  194. # Every unset MagicMock attribute is truthy, and a truthy one here runs the
  195. # plate-detection camera grab against a printer that does not exist.
  196. printer.plate_detection_enabled = False
  197. printer.name = "H2D"
  198. printer.model = "H2D"
  199. printer.ip_address = "192.168.1.211"
  200. printer.access_code = "12345678"
  201. return printer
  202. async def _run_print_start(url, *, probe_hit, added, handshake_blocked=False):
  203. """Drive on_print_start for an eMMC dispatch, returning the probe mock and
  204. the ArchiveService the success path would have used.
  205. ``probe_hit`` is the path the probe serves the file from, or None for a
  206. miss — the download helper returns the winning path rather than a flag so
  207. the hit can be logged by directory (#1820)."""
  208. printer = _printer()
  209. state = MagicMock(current_project_url=url, sdcard=True, sdcard_reported=True)
  210. def execute_router(stmt, *args, **kwargs):
  211. sql = str(stmt).lower()
  212. if "from printers" in sql or "from printer " in sql:
  213. return MagicMock(
  214. scalar_one_or_none=MagicMock(return_value=printer),
  215. scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[printer]))),
  216. )
  217. return MagicMock(
  218. scalar_one_or_none=MagicMock(return_value=None),
  219. scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[]))),
  220. )
  221. session = AsyncMock()
  222. session.__aenter__ = AsyncMock(return_value=session)
  223. session.__aexit__ = AsyncMock()
  224. session.execute = AsyncMock(side_effect=execute_router)
  225. session.commit = AsyncMock()
  226. session.refresh = AsyncMock()
  227. session.add = MagicMock(side_effect=added.append)
  228. probe = AsyncMock(return_value=probe_hit)
  229. archive_service = MagicMock()
  230. archive_service.archive_print = AsyncMock(return_value=MagicMock(id=20, print_name="test", status="printing"))
  231. with (
  232. patch("backend.app.main.async_session") as session_maker,
  233. patch("backend.app.main.notification_service") as notif,
  234. patch("backend.app.main.smart_plug_manager") as plug,
  235. patch("backend.app.main.ws_manager") as ws,
  236. patch("backend.app.main.mqtt_relay") as relay,
  237. patch("backend.app.main.printer_manager") as pm,
  238. patch("backend.app.main.download_file_try_paths_async", new=probe),
  239. patch("backend.app.main.download_file_async", new=AsyncMock(return_value=False)),
  240. patch("backend.app.main.with_ftp_retry", new=AsyncMock(return_value=False)),
  241. patch("backend.app.main.get_cached_3mf", return_value=None),
  242. patch("backend.app.main.cache_3mf_download") as cache,
  243. patch("backend.app.services.bambu_ftp.list_files_async", new=AsyncMock(return_value=[])),
  244. patch("backend.app.main.ftps_handshake_blocked", return_value=handshake_blocked),
  245. patch("backend.app.main.get_ftp_retry_settings", new=AsyncMock(return_value=(False, 3, 2.0, 30))),
  246. patch("backend.app.main.ArchiveService", return_value=archive_service),
  247. patch("backend.app.main.peek_plate_index_in_3mf", return_value=None),
  248. patch("backend.app.main._record_energy_start", new_callable=AsyncMock),
  249. patch("backend.app.main._send_print_start_notification", new_callable=AsyncMock),
  250. patch("backend.app.main._maybe_start_layer_timelapse"),
  251. patch("backend.app.main._capture_timelapse_baseline_at_start", new_callable=AsyncMock),
  252. ):
  253. session_maker.return_value = session
  254. notif.on_print_start = AsyncMock()
  255. plug.on_print_start = AsyncMock()
  256. ws.send_print_start = AsyncMock()
  257. ws.send_archive_created = AsyncMock()
  258. ws.send_archive_updated = AsyncMock()
  259. relay.on_print_start = AsyncMock()
  260. relay.on_archive_created = AsyncMock()
  261. pm.get_status = MagicMock(return_value=state)
  262. pm.get_client = MagicMock(return_value=None)
  263. pm.get_printer = MagicMock(return_value=MagicMock(serial_number="TEST2856"))
  264. from backend.app.main import on_print_start
  265. await on_print_start(1, {"filename": "/data/Metadata/plate_1.gcode", "subtask_name": "test"})
  266. return probe, archive_service, cache
  267. def _fallback(added):
  268. for row in added:
  269. extra = getattr(row, "extra_data", None)
  270. if isinstance(extra, dict) and extra.get("no_3mf_available"):
  271. return row
  272. return None
  273. class TestPrintStart:
  274. @pytest.mark.asyncio
  275. async def test_a_file_the_printer_serves_anyway_is_archived_in_full(self):
  276. """The reported regression, end to end: eMMC dispatch, file present on
  277. the card, and the archive gets the real 3MF instead of a name."""
  278. added = []
  279. probe, service, cache = await _run_print_start(
  280. "brtc://emmc/test.gcode.3mf", probe_hit="/cache/test.gcode.3mf", added=added
  281. )
  282. probe.assert_awaited_once()
  283. assert probe.await_args.args[2] == ftp_probe_paths("test.gcode.3mf")
  284. service.archive_print.assert_awaited_once()
  285. assert Path(service.archive_print.await_args.kwargs["source_file"]).name == "test.gcode.3mf"
  286. assert _fallback(added) is None, "a fallback archive here is the bug"
  287. @pytest.mark.asyncio
  288. async def test_the_hit_names_the_directory_that_served_it(self, caplog):
  289. """#1820: a printer that keeps uploads for weeks can serve a same-named
  290. copy of an earlier slice. Logging only the filename made that mismatch
  291. invisible; the directory is what makes it diagnosable."""
  292. added = []
  293. with caplog.at_level(logging.INFO, logger="backend.app.main"):
  294. await _run_print_start("brtc://emmc/test.gcode.3mf", probe_hit="/cache/test.gcode.3mf", added=added)
  295. found = [r.getMessage() for r in caplog.records if "even though the printer reported" in r.getMessage()]
  296. assert found, "the probe hit is not logged at all"
  297. assert "/cache/test.gcode.3mf" in found[0]
  298. @pytest.mark.asyncio
  299. async def test_the_probed_file_is_shared_with_the_cover_endpoint(self):
  300. """Same 3MF, one transfer. The cover endpoint runs seconds later while
  301. the frontend opens the card, and re-fetching 19 MB over the printer's
  302. single FTP socket is what produced #972's 425 storm."""
  303. added = []
  304. _probe, _service, cache = await _run_print_start(
  305. "brtc://emmc/test.gcode.3mf", probe_hit="/cache/test.gcode.3mf", added=added
  306. )
  307. cache.assert_called_once()
  308. assert cache.call_args.args[1] == "test.gcode.3mf"
  309. @pytest.mark.asyncio
  310. async def test_a_miss_still_ends_in_the_cheap_fallback(self):
  311. """#2780's printers are still out there: when the probe finds nothing,
  312. the reason has to survive to the archive card, which is what stops the
  313. banner telling an H2C owner to switch on a setting that is already on.
  314. """
  315. added = []
  316. probe, service, _cache = await _run_print_start("brtc://emmc/test.gcode.3mf", probe_hit=None, added=added)
  317. probe.assert_awaited_once()
  318. service.archive_print.assert_not_awaited()
  319. assert _fallback(added).extra_data["no_3mf_reason"] == "internal_storage"
  320. @pytest.mark.asyncio
  321. async def test_no_probe_while_the_file_service_is_in_cool_off(self):
  322. """The handshake is failing below the path level, so the probe would
  323. only re-run the failure that put the printer in cool-off (#2780)."""
  324. added = []
  325. probe, _service, _cache = await _run_print_start(
  326. "brtc://emmc/test.gcode.3mf", probe_hit="/cache/test.gcode.3mf", added=added, handshake_blocked=True
  327. )
  328. probe.assert_not_awaited()
  329. assert _fallback(added).extra_data["no_3mf_reason"] == "internal_storage"
  330. @pytest.mark.asyncio
  331. async def test_a_cool_off_on_an_emmc_job_schedules_no_retry(self):
  332. """#2957 added a retry for a cool-off give-up, because that one clears
  333. in minutes with the file still on the printer. This is not that: the
  334. file is on internal eMMC and will not appear at any FTPS path however
  335. long we wait, so the retry must not be scheduled here."""
  336. from backend.app.main import _fallback_3mf_retry_tasks
  337. added = []
  338. before = dict(_fallback_3mf_retry_tasks)
  339. with patch("backend.app.main._schedule_fallback_3mf_retry") as schedule:
  340. await _run_print_start("brtc://emmc/test.gcode.3mf", probe_hit=None, added=added, handshake_blocked=True)
  341. schedule.assert_not_called()
  342. assert _fallback_3mf_retry_tasks == before
  343. @pytest.mark.asyncio
  344. async def test_a_gcode_job_is_not_probed_for(self):
  345. """Nothing names a 3MF, so there is no name to ask about and the skip
  346. stays exactly as cheap as #2780 made it."""
  347. added = []
  348. probe, _service, _cache = await _run_print_start(
  349. "brtc://emmc/plate_1.gcode", probe_hit="/cache/test.gcode.3mf", added=added
  350. )
  351. probe.assert_not_awaited()
  352. assert _fallback(added) is not None