test_internal_storage_probe_2856.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420
  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_gcode_job_is_not_probed_for(self):
  332. """Nothing names a 3MF, so there is no name to ask about and the skip
  333. stays exactly as cheap as #2780 made it."""
  334. added = []
  335. probe, _service, _cache = await _run_print_start(
  336. "brtc://emmc/plate_1.gcode", probe_hit="/cache/test.gcode.3mf", added=added
  337. )
  338. probe.assert_not_awaited()
  339. assert _fallback(added) is not None