test_internal_storage_probe_2856.py 17 KB

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