test_bambu_ftp.py 80 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003
  1. """Comprehensive FTP test suite for BambuFTPClient.
  2. Tests against a real mock implicit FTPS server, covering:
  3. - Connection (auth, SSL modes, timeout, caching)
  4. - File listing
  5. - Download (bytes, to_file, 0-byte regression)
  6. - Upload (chunked transfer, progress, error codes)
  7. - Delete
  8. - File size
  9. - Storage info (AVBL, directory scan, diagnose_storage)
  10. - Model-specific behavior (X1C prot_p, A1 prot_c fallback)
  11. - Async wrappers
  12. - Failure injection scenarios (regressions for 0.1.8 bugs)
  13. """
  14. import asyncio
  15. import logging
  16. import socket
  17. import threading
  18. import time
  19. from pathlib import Path
  20. from unittest.mock import patch
  21. import pytest
  22. from backend.app.services import bambu_ftp
  23. from backend.app.services.bambu_ftp import (
  24. BambuFTPClient,
  25. FileNotOnPrinterError,
  26. cache_3mf_download,
  27. clear_3mf_cache,
  28. delete_file_async,
  29. download_file_async,
  30. download_file_try_paths_async,
  31. get_cached_3mf,
  32. list_files_async,
  33. normalize_3mf_name,
  34. upload_file_async,
  35. with_ftp_retry,
  36. )
  37. # Brief delay to allow pyftpdlib to flush uploaded files to disk.
  38. # Needed because upload_file() skips voidresp() for all models,
  39. # so the server may still be processing the data channel close event.
  40. _UPLOAD_FLUSH_DELAY = 0.3
  41. # ---------------------------------------------------------------------------
  42. # TestConnection
  43. # ---------------------------------------------------------------------------
  44. class TestConnection:
  45. """Tests for FTP connect/disconnect behavior."""
  46. def test_connect_success(self, ftp_client_factory):
  47. """Successful implicit FTPS connection and login."""
  48. client = ftp_client_factory()
  49. assert client.connect() is True
  50. client.disconnect()
  51. def test_connect_wrong_access_code(self, ftp_client_factory):
  52. """Wrong access code returns False."""
  53. client = ftp_client_factory(access_code="wrongcode")
  54. assert client.connect() is False
  55. def test_connect_unreachable_host(self, ftp_server):
  56. """Unreachable host returns False."""
  57. client = BambuFTPClient(
  58. ip_address="192.0.2.1", # TEST-NET, guaranteed unreachable
  59. access_code="12345678",
  60. timeout=1.0,
  61. printer_model="X1C",
  62. )
  63. client.FTP_PORT = ftp_server.port
  64. assert client.connect() is False
  65. def test_connect_timeout(self, ftp_server):
  66. """Very short timeout triggers timeout error."""
  67. client = BambuFTPClient(
  68. ip_address="192.0.2.1",
  69. access_code="12345678",
  70. timeout=0.001, # Extremely short
  71. printer_model="X1C",
  72. )
  73. client.FTP_PORT = ftp_server.port
  74. assert client.connect() is False
  75. def test_disconnect_clean(self, ftp_client_factory):
  76. """Clean disconnect after successful connect."""
  77. client = ftp_client_factory()
  78. client.connect()
  79. client.disconnect()
  80. assert client._ftp is None
  81. def test_disconnect_without_connect(self, ftp_client_factory):
  82. """Disconnect without connect does not raise."""
  83. client = ftp_client_factory()
  84. client.disconnect() # Should not raise
  85. assert client._ftp is None
  86. def test_x1c_uses_prot_p(self, ftp_client_factory):
  87. """X1C model connects with prot_p (protected data channel)."""
  88. client = ftp_client_factory(printer_model="X1C")
  89. assert client.connect() is True
  90. assert client._should_use_prot_c() is False
  91. client.disconnect()
  92. def test_a1_defaults_prot_p(self, ftp_client_factory):
  93. """A1 model defaults to prot_p when no cache exists."""
  94. client = ftp_client_factory(printer_model="A1")
  95. assert client._should_use_prot_c() is False
  96. assert client.connect() is True
  97. client.disconnect()
  98. def test_a1_force_prot_c(self, ftp_client_factory):
  99. """A1 model with force_prot_c uses clear data channel."""
  100. client = ftp_client_factory(printer_model="A1", force_prot_c=True)
  101. assert client._should_use_prot_c() is True
  102. assert client.connect() is True
  103. client.disconnect()
  104. def test_cached_mode_respected(self, ftp_client_factory):
  105. """Cached mode is used on subsequent connections."""
  106. BambuFTPClient.cache_mode("127.0.0.1", "prot_c")
  107. client = ftp_client_factory(printer_model="A1")
  108. assert client._should_use_prot_c() is True
  109. assert client.connect() is True
  110. client.disconnect()
  111. # ---------------------------------------------------------------------------
  112. # TestDisconnectServerGone — isolated class because server.stop() calls
  113. # close_all() which nukes all asyncore sockets globally.
  114. # ---------------------------------------------------------------------------
  115. class TestDisconnectServerGone:
  116. """Test disconnect behavior when the server has stopped."""
  117. def test_disconnect_after_server_gone(self, ftp_certs, tmp_path):
  118. """Disconnect after server has stopped does not raise.
  119. disconnect() catches OSError, ftplib.Error, and EOFError so that
  120. best-effort cleanup never propagates exceptions to the caller.
  121. """
  122. from backend.tests.unit.services.mock_ftp_server import (
  123. MockBambuFTPServer,
  124. )
  125. from .conftest import _find_free_port
  126. cert_path, key_path = ftp_certs
  127. port = _find_free_port()
  128. server = MockBambuFTPServer("127.0.0.1", port, str(tmp_path), cert_path, key_path)
  129. server.start()
  130. client = BambuFTPClient("127.0.0.1", "12345678", timeout=5.0)
  131. client.FTP_PORT = port
  132. client.connect()
  133. server.stop()
  134. # Should not raise — disconnect() catches all connection errors
  135. client.disconnect()
  136. assert client._ftp is None
  137. # ---------------------------------------------------------------------------
  138. # TestListFiles
  139. # ---------------------------------------------------------------------------
  140. class TestListFiles:
  141. """Tests for directory listing."""
  142. def test_list_empty_directory(self, ftp_client_factory):
  143. """Listing an empty directory returns empty list."""
  144. client = ftp_client_factory()
  145. client.connect()
  146. files = client.list_files("/cache")
  147. assert files == []
  148. client.disconnect()
  149. def test_list_directory_with_files(self, ftp_client_factory, ftp_server):
  150. """Files in directory are listed correctly."""
  151. ftp_server.add_file("cache/test.3mf", b"x" * 1024)
  152. ftp_server.add_file("cache/test2.gcode", b"y" * 512)
  153. client = ftp_client_factory()
  154. client.connect()
  155. files = client.list_files("/cache")
  156. names = {f["name"] for f in files}
  157. assert "test.3mf" in names
  158. assert "test2.gcode" in names
  159. client.disconnect()
  160. def test_directories_marked(self, ftp_client_factory, ftp_server):
  161. """Subdirectories are identified with is_directory=True."""
  162. ftp_server.add_directory("model/subdir")
  163. client = ftp_client_factory()
  164. client.connect()
  165. files = client.list_files("/model")
  166. dirs = [f for f in files if f["is_directory"]]
  167. assert len(dirs) >= 1
  168. assert dirs[0]["name"] == "subdir"
  169. client.disconnect()
  170. def test_nonexistent_path_returns_empty(self, ftp_client_factory):
  171. """Listing a nonexistent path returns empty list."""
  172. client = ftp_client_factory()
  173. client.connect()
  174. files = client.list_files("/nonexistent/path")
  175. assert files == []
  176. client.disconnect()
  177. def test_file_sizes_and_paths(self, ftp_client_factory, ftp_server):
  178. """File sizes and full paths are parsed correctly."""
  179. ftp_server.add_file("cache/sized.bin", b"a" * 2048)
  180. client = ftp_client_factory()
  181. client.connect()
  182. files = client.list_files("/cache")
  183. sized = [f for f in files if f["name"] == "sized.bin"]
  184. assert len(sized) == 1
  185. assert sized[0]["size"] == 2048
  186. assert sized[0]["path"] == "/cache/sized.bin"
  187. client.disconnect()
  188. # ---------------------------------------------------------------------------
  189. # TestDownload
  190. # ---------------------------------------------------------------------------
  191. class TestDownload:
  192. """Tests for file download operations."""
  193. def test_download_file_returns_bytes(self, ftp_client_factory, ftp_server):
  194. """download_file() returns file content as bytes."""
  195. content = b"Hello FTP World!"
  196. ftp_server.add_file("cache/hello.txt", content)
  197. client = ftp_client_factory()
  198. client.connect()
  199. result = client.download_file("/cache/hello.txt")
  200. assert result == content
  201. client.disconnect()
  202. def test_download_file_missing(self, ftp_client_factory):
  203. """download_file() returns None for missing file."""
  204. client = ftp_client_factory()
  205. client.connect()
  206. result = client.download_file("/cache/does_not_exist.txt")
  207. assert result is None
  208. client.disconnect()
  209. def test_download_to_file_writes_to_disk(self, ftp_client_factory, ftp_server, tmp_path):
  210. """download_to_file() writes content to local filesystem."""
  211. content = b"Downloaded content"
  212. ftp_server.add_file("cache/dl.bin", content)
  213. local = tmp_path / "output" / "dl.bin"
  214. client = ftp_client_factory()
  215. client.connect()
  216. result = client.download_to_file("/cache/dl.bin", local)
  217. assert result is True
  218. assert local.read_bytes() == content
  219. client.disconnect()
  220. def test_download_to_file_creates_parent_dirs(self, ftp_client_factory, ftp_server, tmp_path):
  221. """download_to_file() creates parent directories automatically."""
  222. ftp_server.add_file("cache/nested.txt", b"nested content")
  223. local = tmp_path / "deep" / "nested" / "path" / "nested.txt"
  224. client = ftp_client_factory()
  225. client.connect()
  226. result = client.download_to_file("/cache/nested.txt", local)
  227. assert result is True
  228. assert local.exists()
  229. client.disconnect()
  230. def test_zero_byte_download_returns_false(self, ftp_client_factory, ftp_server, tmp_path):
  231. """0-byte download returns False and cleans up (regression test)."""
  232. ftp_server.add_file("cache/empty.bin", b"")
  233. local = tmp_path / "empty.bin"
  234. client = ftp_client_factory()
  235. client.connect()
  236. result = client.download_to_file("/cache/empty.bin", local)
  237. assert result is False
  238. assert not local.exists()
  239. client.disconnect()
  240. def test_download_to_file_prefers_fresh_server_size_over_stale_client_hint(
  241. self, ftp_client_factory, ftp_server, tmp_path
  242. ):
  243. """The immediate printer SIZE result overrides a stale browser hint."""
  244. ftp_server.add_file("cache/short.bin", b"short")
  245. local = tmp_path / "short.bin"
  246. client = ftp_client_factory()
  247. client.connect()
  248. try:
  249. assert client.download_to_file("/cache/short.bin", local, expected_size=100) is True
  250. assert local.read_bytes() == b"short"
  251. finally:
  252. client.disconnect()
  253. def test_download_to_file_enforces_actual_byte_limit(self, ftp_client_factory, ftp_server, tmp_path):
  254. """Untrusted size hints cannot let a transfer exceed the server-side cap."""
  255. ftp_server.add_file("cache/oversize.bin", b"too large")
  256. local = tmp_path / "oversize.bin"
  257. client = ftp_client_factory()
  258. client.connect()
  259. try:
  260. with pytest.raises(bambu_ftp.DownloadLimitExceeded):
  261. client.download_to_file("/cache/oversize.bin", local, max_bytes=3)
  262. assert not local.exists()
  263. finally:
  264. client.disconnect()
  265. def test_download_to_file_uses_server_size_when_client_hint_is_omitted(self, tmp_path):
  266. """A clean short RETR is rejected using SIZE, without trusting a caller hint."""
  267. local = tmp_path / "server-sized.bin"
  268. client = BambuFTPClient("127.0.0.1", "12345678")
  269. class FakeFTP:
  270. def size(self, _remote_path):
  271. return 100
  272. def retrbinary(self, _command, callback):
  273. callback(b"short")
  274. client._ftp = FakeFTP()
  275. assert client.download_to_file("/cache/server-sized.bin", local) is False
  276. assert not local.exists()
  277. def test_download_to_file_falls_back_to_client_hint_when_size_is_unsupported(self, tmp_path):
  278. local = tmp_path / "hint-sized.bin"
  279. client = BambuFTPClient("127.0.0.1", "12345678")
  280. class FakeFTP:
  281. def size(self, _remote_path):
  282. raise bambu_ftp.ftplib.error_perm("502 SIZE unsupported")
  283. def retrbinary(self, _command, callback):
  284. callback(b"short")
  285. client._ftp = FakeFTP()
  286. assert client.download_to_file("/cache/hint-sized.bin", local, expected_size=100) is False
  287. assert not local.exists()
  288. def test_download_to_file_missing_raises_not_on_printer(self, ftp_client_factory, tmp_path):
  289. """Missing file raises FileNotOnPrinterError so callers can short-circuit
  290. the retry loop — 550 means the file isn't there and retrying won't help."""
  291. from backend.app.services.bambu_ftp import FileNotOnPrinterError
  292. local = tmp_path / "missing.bin"
  293. client = ftp_client_factory()
  294. client.connect()
  295. try:
  296. with pytest.raises(FileNotOnPrinterError):
  297. client.download_to_file("/cache/no_such_file.bin", local)
  298. finally:
  299. client.disconnect()
  300. def test_download_large_file(self, ftp_client_factory, ftp_server):
  301. """Large file download (>1MB) works correctly."""
  302. large_content = b"X" * (1024 * 1024 + 500) # ~1MB + 500 bytes
  303. ftp_server.add_file("cache/large.bin", large_content)
  304. client = ftp_client_factory()
  305. client.connect()
  306. result = client.download_file("/cache/large.bin")
  307. assert result == large_content
  308. client.disconnect()
  309. def test_download_not_connected(self):
  310. """download_file() returns None when not connected."""
  311. client = BambuFTPClient("127.0.0.1", "12345678")
  312. assert client.download_file("/cache/test.bin") is None
  313. # ---------------------------------------------------------------------------
  314. # TestUpload
  315. # ---------------------------------------------------------------------------
  316. class TestUpload:
  317. """Tests for file upload operations."""
  318. def test_upload_success(self, ftp_client_factory, ftp_server, tmp_path):
  319. """Successful upload via transfercmd (not storbinary)."""
  320. content = b"Upload test content"
  321. local = tmp_path / "upload.3mf"
  322. local.write_bytes(content)
  323. client = ftp_client_factory()
  324. client.connect()
  325. result = client.upload_file(local, "/cache/upload.3mf")
  326. assert result is True
  327. client.disconnect()
  328. # Verify via fresh connection (upload_file skips voidresp() for all
  329. # models, so the original session can't be reused for download)
  330. time.sleep(_UPLOAD_FLUSH_DELAY)
  331. client2 = ftp_client_factory()
  332. client2.connect()
  333. downloaded = client2.download_file("/cache/upload.3mf")
  334. assert downloaded == content
  335. client2.disconnect()
  336. def test_upload_progress_callback(self, ftp_client_factory, ftp_server, tmp_path):
  337. """Progress callback receives updates during upload."""
  338. content = b"P" * 2048
  339. local = tmp_path / "progress.bin"
  340. local.write_bytes(content)
  341. progress_calls = []
  342. def on_progress(uploaded, total):
  343. progress_calls.append((uploaded, total))
  344. client = ftp_client_factory()
  345. client.connect()
  346. client.upload_file(local, "/cache/progress.bin", on_progress)
  347. assert len(progress_calls) >= 1
  348. # Last call should report full file uploaded
  349. assert progress_calls[-1][0] == len(content)
  350. assert progress_calls[-1][1] == len(content)
  351. client.disconnect()
  352. def test_upload_not_connected(self, tmp_path):
  353. """Upload when not connected returns False."""
  354. local = tmp_path / "test.bin"
  355. local.write_bytes(b"data")
  356. client = BambuFTPClient("127.0.0.1", "12345678")
  357. assert client.upload_file(local, "/cache/test.bin") is False
  358. def test_upload_553_no_sd_card(self, ftp_client_factory, ftp_server, tmp_path):
  359. """553 error (no SD card) returns False."""
  360. ftp_server.inject_failure("STOR", 553, "Could not create file.")
  361. local = tmp_path / "test.bin"
  362. local.write_bytes(b"data")
  363. client = ftp_client_factory()
  364. client.connect()
  365. result = client.upload_file(local, "/cache/test.bin")
  366. assert result is False
  367. client.disconnect()
  368. def test_upload_550_permission_denied(self, ftp_client_factory, ftp_server, tmp_path):
  369. """550 error (permission denied) returns False."""
  370. ftp_server.inject_failure("STOR", 550, "Permission denied.")
  371. local = tmp_path / "test.bin"
  372. local.write_bytes(b"data")
  373. client = ftp_client_factory()
  374. client.connect()
  375. result = client.upload_file(local, "/cache/test.bin")
  376. assert result is False
  377. client.disconnect()
  378. def test_upload_552_storage_full(self, ftp_client_factory, ftp_server, tmp_path):
  379. """552 error (storage full) returns False."""
  380. ftp_server.inject_failure("STOR", 552, "Storage quota exceeded.")
  381. local = tmp_path / "test.bin"
  382. local.write_bytes(b"data")
  383. client = ftp_client_factory()
  384. client.connect()
  385. result = client.upload_file(local, "/cache/test.bin")
  386. assert result is False
  387. client.disconnect()
  388. @pytest.mark.parametrize(("server_size", "logged_error"), [(100, True), (1024, False), (None, False)])
  389. def test_upload_without_a_226_is_checked_on_a_new_connection(
  390. self, ftp_client_factory, ftp_server, tmp_path, caplog, server_size, logged_error
  391. ):
  392. """No 226 within the wait (an H2D can take 30+ s) still lets the upload
  393. proceed, but its size is read back on a fresh connection, since the
  394. old one may be out of step, and a mismatch is logged as an error."""
  395. import logging
  396. import backend.app.services.bambu_ftp as ftp_mod
  397. local = tmp_path / "test.bin"
  398. local.write_bytes(b"data" * 256)
  399. client = ftp_client_factory()
  400. client.connect()
  401. def never_answers():
  402. raise TimeoutError("timed out")
  403. client._ftp.voidresp = never_answers
  404. checked: list[str] = []
  405. def fake_size(c, remote_path):
  406. checked.append(remote_path)
  407. return server_size
  408. with (
  409. patch.object(ftp_mod, "_size_on_new_connection", fake_size),
  410. caplog.at_level(logging.INFO, logger="backend.app.services.bambu_ftp"),
  411. ):
  412. assert client.upload_file(local, "/cache/test.bin") is True
  413. client.disconnect()
  414. assert checked == ["/cache/test.bin"]
  415. errors = [r for r in caplog.records if r.levelno >= logging.ERROR]
  416. assert bool(errors) is logged_error
  417. def test_the_read_back_reads_the_size_on_its_own_connection(self, ftp_client_factory, ftp_server):
  418. import backend.app.services.bambu_ftp as ftp_mod
  419. client = ftp_client_factory()
  420. client.connect()
  421. assert client.upload_bytes(b"x" * 300, "/cache/r.bin") is True
  422. assert ftp_mod._size_on_new_connection(client, "/cache/r.bin") == 300
  423. client.disconnect()
  424. def test_a_refused_read_back_does_not_pause_the_printer(self, ftp_client_factory, ftp_server):
  425. """A second session can be refused while the first still waits; that
  426. must not open the cool-off that skips the printer's archive downloads."""
  427. import ssl
  428. import backend.app.services.bambu_ftp as ftp_mod
  429. client = ftp_client_factory()
  430. with patch.object(ftp_mod.ImplicitFTP_TLS, "connect", side_effect=ssl.SSLError("refused")):
  431. assert ftp_mod._size_on_new_connection(client, "/cache/r.bin") is None
  432. assert not ftp_mod.BambuFTPClient.handshake_blocked("127.0.0.1")
  433. def test_upload_bytes_without_a_226_is_checked_too(self, ftp_client_factory, ftp_server):
  434. import backend.app.services.bambu_ftp as ftp_mod
  435. client = ftp_client_factory()
  436. client.connect()
  437. def never_answers():
  438. raise TimeoutError("timed out")
  439. client._ftp.voidresp = never_answers
  440. checked: list[str] = []
  441. def fake_size(c, remote_path):
  442. checked.append(remote_path)
  443. return 3
  444. with patch.object(ftp_mod, "_size_on_new_connection", fake_size):
  445. assert client.upload_bytes(b"abc", "/cache/b.bin") is True
  446. client.disconnect()
  447. assert checked == ["/cache/b.bin"]
  448. def test_upload_426_with_intact_file_proceeds(self, ftp_client_factory, ftp_server, tmp_path):
  449. """Some P2S firmware revisions return 426 on voidresp() even when the
  450. file landed fully (TLS data-channel close races the 226). #1417
  451. follow-up — verify via SIZE: when server size matches, proceed with
  452. a warning instead of failing the dispatch.
  453. Pre-#1417 the catch raised unconditionally and the reporter saw 11
  454. retries fail in a row even though every upload was actually
  455. succeeding on the printer side (v0.2.4.1 worked because the prior
  456. proceed-with-warning branch tolerated the noise).
  457. """
  458. import ftplib # nosec B402 — tests need the real ftplib to construct mock 426 responses
  459. local = tmp_path / "test.bin"
  460. local.write_bytes(b"data" * 256) # 1024 bytes
  461. client = ftp_client_factory()
  462. client.connect()
  463. def raise_426():
  464. raise ftplib.error_temp("426 Failure reading network stream.")
  465. def fake_size(_path):
  466. # Real P2S firmware: voidresp returns 426 but the file IS on
  467. # the SD card at its full size. Mock can't reproduce both
  468. # halves naturally because pyftpdlib only flushes on a clean
  469. # voidresp, so we inject SIZE explicitly to model the
  470. # printer-side state the user observes.
  471. return 1024
  472. client._ftp.voidresp = raise_426
  473. client._ftp.size = fake_size
  474. result = client.upload_file(local, "/cache/test.bin")
  475. assert result is True, "intact file (SIZE match) tolerates 426 noise"
  476. client.disconnect()
  477. def test_upload_426_with_intact_file_logs_at_info_not_warning(
  478. self, ftp_client_factory, ftp_server, tmp_path, caplog
  479. ):
  480. """A verified-intact 426 is how Bambu FTPS normally ends a transfer,
  481. not a fault, so it must not be a WARNING (#2987).
  482. It fired 54 times in one support bundle, every one followed by a
  483. completed upload, and buried the 26 TLS handshake failures in the same
  484. log that actually cost the reporter two prints. The truncated case below
  485. is still an error -- this only moves the one we have already verified.
  486. """
  487. import ftplib # nosec B402 — tests need the real ftplib to construct mock 426 responses
  488. import logging
  489. local = tmp_path / "test.bin"
  490. local.write_bytes(b"data" * 256) # 1024 bytes
  491. client = ftp_client_factory()
  492. client.connect()
  493. def raise_426():
  494. raise ftplib.error_temp("426 Failure reading network stream.")
  495. client._ftp.voidresp = raise_426
  496. client._ftp.size = lambda _path: 1024
  497. with caplog.at_level(logging.INFO, logger="backend.app.services.bambu_ftp"):
  498. assert client.upload_file(local, "/cache/test.bin") is True
  499. client.disconnect()
  500. intact = [r for r in caplog.records if "file is intact on the" in r.getMessage()]
  501. assert intact, "the proceed path must still say why it proceeded"
  502. assert [r.levelno for r in intact] == [logging.INFO] * len(intact)
  503. def test_upload_426_with_truncated_file_still_logs_an_error(self, ftp_client_factory, ftp_server, tmp_path, caplog):
  504. """The half that must stay loud: bytes that did not verify."""
  505. import ftplib # nosec B402 — tests need the real ftplib to construct mock 426 responses
  506. import logging
  507. local = tmp_path / "test.bin"
  508. local.write_bytes(b"data" * 256)
  509. client = ftp_client_factory()
  510. client.connect()
  511. def raise_426():
  512. raise ftplib.error_temp("426 Failure reading network stream.")
  513. client._ftp.voidresp = raise_426
  514. client._ftp.size = lambda _path: 100
  515. with caplog.at_level(logging.INFO, logger="backend.app.services.bambu_ftp"):
  516. assert client.upload_file(local, "/cache/test.bin") is False
  517. client.disconnect()
  518. rejected = [r for r in caplog.records if "rejected by printer" in r.getMessage()]
  519. assert rejected and all(r.levelno == logging.ERROR for r in rejected)
  520. def test_upload_426_with_truncated_file_returns_false(self, ftp_client_factory, ftp_server, tmp_path):
  521. """The original #1401 fix is preserved: when SIZE confirms the file
  522. isn't on the server at full size (or SIZE itself fails), the upload
  523. must fail so the dispatcher doesn't send a print command for a
  524. partial 3MF."""
  525. import ftplib # nosec B402 — tests need the real ftplib to construct mock 426 responses
  526. local = tmp_path / "test.bin"
  527. local.write_bytes(b"data" * 256)
  528. client = ftp_client_factory()
  529. client.connect()
  530. def raise_426():
  531. raise ftplib.error_temp("426 Failure reading network stream.")
  532. # Make SIZE report a smaller value — file is genuinely truncated.
  533. def fake_size(_path):
  534. return 100
  535. client._ftp.voidresp = raise_426
  536. client._ftp.size = fake_size
  537. result = client.upload_file(local, "/cache/test.bin")
  538. assert result is False, "truncated file (SIZE mismatch) must fail"
  539. client.disconnect()
  540. def test_upload_426_with_size_check_failing_returns_false(self, ftp_client_factory, ftp_server, tmp_path):
  541. """If SIZE itself fails (e.g. server too broken to answer), assume
  542. the worst and fail — better a retry than a print on a partial file.
  543. """
  544. import ftplib # nosec B402 — tests need the real ftplib to construct mock 426 responses
  545. local = tmp_path / "test.bin"
  546. local.write_bytes(b"data" * 256)
  547. client = ftp_client_factory()
  548. client.connect()
  549. def raise_426():
  550. raise ftplib.error_temp("426 Failure reading network stream.")
  551. def raise_size(_path):
  552. raise ftplib.error_perm("550 File not found.")
  553. client._ftp.voidresp = raise_426
  554. client._ftp.size = raise_size
  555. result = client.upload_file(local, "/cache/test.bin")
  556. assert result is False
  557. client.disconnect()
  558. def test_upload_bytes_426_with_intact_file_proceeds(self, ftp_client_factory, ftp_server):
  559. """upload_bytes() mirrors the same SIZE-verify logic as upload_file."""
  560. import ftplib # nosec B402 — tests need the real ftplib to construct mock 426 responses
  561. client = ftp_client_factory()
  562. client.connect()
  563. data = b"x" * 1024
  564. def raise_426():
  565. raise ftplib.error_temp("426 Failure reading network stream.")
  566. def fake_size(_path):
  567. return 1024 # printer-side file matches expected size
  568. client._ftp.voidresp = raise_426
  569. client._ftp.size = fake_size
  570. result = client.upload_bytes(data, "/cache/bytes.bin")
  571. assert result is True
  572. client.disconnect()
  573. def test_upload_bytes_426_with_truncated_file_returns_false(self, ftp_client_factory, ftp_server):
  574. """The truncated branch for upload_bytes()."""
  575. import ftplib # nosec B402 — tests need the real ftplib to construct mock 426 responses
  576. client = ftp_client_factory()
  577. client.connect()
  578. data = b"x" * 1024
  579. def raise_426():
  580. raise ftplib.error_temp("426 Failure reading network stream.")
  581. def fake_size(_path):
  582. return 100
  583. client._ftp.voidresp = raise_426
  584. client._ftp.size = fake_size
  585. result = client.upload_bytes(data, "/cache/bytes.bin")
  586. assert result is False
  587. client.disconnect()
  588. def test_upload_bytes_success(self, ftp_client_factory, ftp_server):
  589. """upload_bytes() writes data to server."""
  590. data = b"Bytes upload content"
  591. client = ftp_client_factory()
  592. client.connect()
  593. result = client.upload_bytes(data, "/cache/bytes.bin")
  594. assert result is True
  595. client.disconnect()
  596. # Verify via fresh connection
  597. time.sleep(_UPLOAD_FLUSH_DELAY)
  598. client2 = ftp_client_factory()
  599. client2.connect()
  600. downloaded = client2.download_file("/cache/bytes.bin")
  601. assert downloaded == data
  602. client2.disconnect()
  603. def test_upload_bytes_failure(self, ftp_client_factory, ftp_server):
  604. """upload_bytes() returns False on STOR failure."""
  605. ftp_server.inject_failure("STOR", 553, "No space.")
  606. client = ftp_client_factory()
  607. client.connect()
  608. result = client.upload_bytes(b"data", "/cache/fail.bin")
  609. assert result is False
  610. client.disconnect()
  611. def test_upload_large_chunked(self, ftp_client_factory, ftp_server, tmp_path):
  612. """Large file upload in chunks completes without error.
  613. Uses 2.5MB to trigger multiple chunks with 64KB CHUNK_SIZE.
  614. Content verification skipped because upload_file() skips
  615. voidresp() for all models, so the server may still be flushing
  616. when we check. The upload result=True confirms the client sent
  617. all chunks without error.
  618. """
  619. content = b"C" * (1024 * 1024 * 2 + 512 * 1024)
  620. local = tmp_path / "large.bin"
  621. local.write_bytes(content)
  622. progress_calls = []
  623. def on_progress(uploaded, total):
  624. progress_calls.append((uploaded, total))
  625. client = ftp_client_factory()
  626. client.connect()
  627. result = client.upload_file(local, "/cache/large.bin", on_progress)
  628. assert result is True
  629. # Verify many chunks were sent (2.5MB / 64KB = 40 chunks)
  630. assert len(progress_calls) >= 38
  631. assert progress_calls[-1][0] == len(content)
  632. client.disconnect()
  633. # ---------------------------------------------------------------------------
  634. # TestDelete
  635. # ---------------------------------------------------------------------------
  636. class TestDelete:
  637. """Tests for file deletion."""
  638. def test_delete_success(self, ftp_client_factory, ftp_server):
  639. """Successful file deletion."""
  640. from backend.app.services.bambu_ftp import DeleteResult
  641. ftp_server.add_file("cache/to_delete.bin", b"delete me")
  642. client = ftp_client_factory()
  643. client.connect()
  644. result = client.delete_file("/cache/to_delete.bin")
  645. assert result == DeleteResult.DELETED
  646. assert not ftp_server.file_exists("cache/to_delete.bin")
  647. client.disconnect()
  648. def test_delete_not_found(self, ftp_client_factory):
  649. """Deleting a nonexistent file returns NOT_FOUND (550, #1721)."""
  650. from backend.app.services.bambu_ftp import DeleteResult
  651. client = ftp_client_factory()
  652. client.connect()
  653. result = client.delete_file("/cache/no_such_file.bin")
  654. assert result == DeleteResult.NOT_FOUND
  655. client.disconnect()
  656. def test_delete_not_connected(self):
  657. """Delete when not connected returns FAILED."""
  658. from backend.app.services.bambu_ftp import DeleteResult
  659. client = BambuFTPClient("127.0.0.1", "12345678")
  660. assert client.delete_file("/cache/test.bin") == DeleteResult.FAILED
  661. # ---------------------------------------------------------------------------
  662. # TestFileSize
  663. # ---------------------------------------------------------------------------
  664. class TestFileSize:
  665. """Tests for get_file_size."""
  666. def test_file_size_correct(self, ftp_client_factory, ftp_server):
  667. """Returns correct file size."""
  668. ftp_server.add_file("cache/sized.bin", b"a" * 4096)
  669. client = ftp_client_factory()
  670. client.connect()
  671. size = client.get_file_size("/cache/sized.bin")
  672. assert size == 4096
  673. client.disconnect()
  674. def test_file_size_missing(self, ftp_client_factory):
  675. """Returns None for missing file."""
  676. client = ftp_client_factory()
  677. client.connect()
  678. size = client.get_file_size("/cache/no_file.bin")
  679. assert size is None
  680. client.disconnect()
  681. def test_file_size_not_connected(self):
  682. """Returns None when not connected."""
  683. client = BambuFTPClient("127.0.0.1", "12345678")
  684. assert client.get_file_size("/cache/test.bin") is None
  685. # ---------------------------------------------------------------------------
  686. # TestStorageInfo
  687. # ---------------------------------------------------------------------------
  688. class TestStorageInfo:
  689. """Tests for storage info and diagnostics."""
  690. def test_avbl_parsed(self, ftp_client_factory, ftp_server):
  691. """AVBL response is parsed for free_bytes."""
  692. ftp_server.set_avbl_bytes(5000000000)
  693. client = ftp_client_factory()
  694. client.connect()
  695. info = client.get_storage_info()
  696. assert info is not None
  697. assert info["free_bytes"] == 5000000000
  698. client.disconnect()
  699. def test_used_bytes_from_scan(self, ftp_client_factory, ftp_server):
  700. """used_bytes calculated from directory scan."""
  701. ftp_server.add_file("cache/file1.bin", b"a" * 1000)
  702. ftp_server.add_file("cache/file2.bin", b"b" * 2000)
  703. client = ftp_client_factory()
  704. client.connect()
  705. info = client.get_storage_info()
  706. assert info is not None
  707. assert info["used_bytes"] >= 3000 # At least these two files
  708. client.disconnect()
  709. def test_storage_info_not_connected(self):
  710. """Returns None when not connected."""
  711. client = BambuFTPClient("127.0.0.1", "12345678")
  712. assert client.get_storage_info() is None
  713. def test_diagnose_storage_success(self, ftp_client_factory, ftp_server):
  714. """diagnose_storage() returns connected=True with working diagnostics."""
  715. client = ftp_client_factory()
  716. client.connect()
  717. diag = client.diagnose_storage()
  718. assert diag["connected"] is True
  719. assert diag["can_list_root"] is True
  720. assert diag["can_list_cache"] is True
  721. assert diag["pwd"] is not None
  722. assert diag["storage_info"] is not None
  723. client.disconnect()
  724. def test_diagnose_storage_not_connected(self):
  725. """diagnose_storage() reports not connected."""
  726. client = BambuFTPClient("127.0.0.1", "12345678")
  727. diag = client.diagnose_storage()
  728. assert diag["connected"] is False
  729. assert "FTP not connected" in diag["errors"]
  730. # ---------------------------------------------------------------------------
  731. # TestModelSpecificBehavior
  732. # ---------------------------------------------------------------------------
  733. class TestModelSpecificBehavior:
  734. """Tests for printer model-specific FTP behavior."""
  735. def test_x1c_upload(self, ftp_client_factory, ftp_server, tmp_path):
  736. """X1C upload with session reuse succeeds."""
  737. content = b"X1C upload data"
  738. local = tmp_path / "x1c.3mf"
  739. local.write_bytes(content)
  740. client = ftp_client_factory(printer_model="X1C")
  741. client.connect()
  742. result = client.upload_file(local, "/cache/x1c.3mf")
  743. assert result is True
  744. client.disconnect()
  745. # Verify via fresh connection
  746. time.sleep(_UPLOAD_FLUSH_DELAY)
  747. client2 = ftp_client_factory(printer_model="X1C")
  748. client2.connect()
  749. downloaded = client2.download_file("/cache/x1c.3mf")
  750. assert downloaded == content
  751. client2.disconnect()
  752. def test_a1_upload_prot_c(self, ftp_client_factory, ftp_server, tmp_path):
  753. """A1 model upload with prot_c succeeds."""
  754. content = b"A1 upload data"
  755. local = tmp_path / "a1.3mf"
  756. local.write_bytes(content)
  757. client = ftp_client_factory(printer_model="A1", force_prot_c=True)
  758. client.connect()
  759. result = client.upload_file(local, "/cache/a1.3mf")
  760. assert result is True
  761. client.disconnect()
  762. # Verify via fresh connection
  763. time.sleep(_UPLOAD_FLUSH_DELAY)
  764. client2 = ftp_client_factory(printer_model="A1", force_prot_c=True)
  765. client2.connect()
  766. downloaded = client2.download_file("/cache/a1.3mf")
  767. assert downloaded == content
  768. client2.disconnect()
  769. def test_a1_mini_upload(self, ftp_client_factory, ftp_server, tmp_path):
  770. """A1 Mini model upload succeeds."""
  771. content = b"A1 Mini data"
  772. local = tmp_path / "a1mini.3mf"
  773. local.write_bytes(content)
  774. client = ftp_client_factory(printer_model="A1 Mini", force_prot_c=True)
  775. client.connect()
  776. result = client.upload_file(local, "/cache/a1mini.3mf")
  777. assert result is True
  778. client.disconnect()
  779. def test_p1s_upload(self, ftp_client_factory, ftp_server, tmp_path):
  780. """P1S model upload with session reuse succeeds."""
  781. content = b"P1S upload data"
  782. local = tmp_path / "p1s.3mf"
  783. local.write_bytes(content)
  784. client = ftp_client_factory(printer_model="P1S")
  785. client.connect()
  786. result = client.upload_file(local, "/cache/p1s.3mf")
  787. assert result is True
  788. client.disconnect()
  789. def test_unknown_model_defaults_prot_p(self, ftp_client_factory):
  790. """Unknown model defaults to prot_p."""
  791. client = ftp_client_factory(printer_model="FuturePrinter3000")
  792. assert client._is_a1_model() is False
  793. assert client._should_use_prot_c() is False
  794. assert client.connect() is True
  795. client.disconnect()
  796. def test_mode_cache_persists_and_clears(self, ftp_client_factory):
  797. """Mode cache works within a test and clears between tests."""
  798. # Cache should be empty at start (autouse fixture clears it)
  799. assert BambuFTPClient._mode_cache == {}
  800. # Connect and cache a mode
  801. BambuFTPClient.cache_mode("127.0.0.1", "prot_p")
  802. assert BambuFTPClient._mode_cache["127.0.0.1"] == "prot_p"
  803. # New client for same IP uses cached mode
  804. client = ftp_client_factory(printer_model="A1")
  805. assert client._get_cached_mode() == "prot_p"
  806. assert client._should_use_prot_c() is False
  807. client.disconnect()
  808. # ---------------------------------------------------------------------------
  809. # TestAsyncWrappers
  810. # ---------------------------------------------------------------------------
  811. class TestAsyncWrappers:
  812. """Tests for async wrapper functions using patch_ftp_port fixture."""
  813. @pytest.mark.asyncio
  814. async def test_upload_file_async_success(self, patch_ftp_port, tmp_path):
  815. """upload_file_async succeeds for X1C."""
  816. content = b"async upload"
  817. local = tmp_path / "async_up.3mf"
  818. local.write_bytes(content)
  819. result = await upload_file_async(
  820. "127.0.0.1",
  821. "12345678",
  822. local,
  823. "/cache/async_up.3mf",
  824. timeout=30.0,
  825. printer_model="X1C",
  826. )
  827. assert result is True
  828. @pytest.mark.asyncio
  829. async def test_upload_file_async_a1_fallback(self, patch_ftp_port, tmp_path):
  830. """upload_file_async tries prot_p then falls back to prot_c for A1."""
  831. content = b"a1 async upload"
  832. local = tmp_path / "a1_async.3mf"
  833. local.write_bytes(content)
  834. # For A1 models, if prot_p succeeds we get True.
  835. # If prot_p fails, it tries prot_c. Either way should succeed
  836. # against our mock server which accepts both.
  837. result = await upload_file_async(
  838. "127.0.0.1",
  839. "12345678",
  840. local,
  841. "/cache/a1_async.3mf",
  842. timeout=30.0,
  843. printer_model="A1",
  844. )
  845. assert result is True
  846. @pytest.mark.asyncio
  847. async def test_download_file_async_success(self, patch_ftp_port, tmp_path):
  848. """download_file_async succeeds."""
  849. server = patch_ftp_port
  850. content = b"async download content"
  851. server.add_file("cache/async_dl.bin", content)
  852. local = tmp_path / "async_dl.bin"
  853. result = await download_file_async(
  854. "127.0.0.1",
  855. "12345678",
  856. "/cache/async_dl.bin",
  857. local,
  858. timeout=30.0,
  859. printer_model="X1C",
  860. )
  861. assert result is True
  862. assert local.read_bytes() == content
  863. @pytest.mark.asyncio
  864. async def test_download_file_async_a1_fallback(self, patch_ftp_port, tmp_path):
  865. """download_file_async falls back for A1 models."""
  866. server = patch_ftp_port
  867. server.add_file("cache/a1_dl.bin", b"a1 data")
  868. local = tmp_path / "a1_dl.bin"
  869. result = await download_file_async(
  870. "127.0.0.1",
  871. "12345678",
  872. "/cache/a1_dl.bin",
  873. local,
  874. timeout=30.0,
  875. printer_model="A1",
  876. )
  877. assert result is True
  878. @pytest.mark.asyncio
  879. async def test_download_file_async_timeout_salvages_completed_zombie(self, tmp_path, monkeypatch):
  880. """Executor thread that completes after wait_for timeout is salvaged.
  881. asyncio.wait_for cannot cancel run_in_executor threads, so the FTP
  882. download may still complete after we give up waiting. If the thread
  883. genuinely finished (signalled via completion["success"] and the file
  884. is on disk), download_file_async should return True rather than False.
  885. Regression for #972: A1 user with 14 MB 3MF hit the hardcoded 60s
  886. timeout, but the download thread finished ~45s later. The successful
  887. file was written to disk but the async wrapper returned False, so the
  888. archive was created as a fallback with no 3MF data.
  889. """
  890. from backend.app.services import bambu_ftp
  891. # Clear mode cache so prot_p path is exercised.
  892. bambu_ftp.BambuFTPClient._mode_cache.pop("127.0.0.1", None)
  893. local = tmp_path / "zombie.bin"
  894. expected_content = b"late arrival but complete"
  895. class FakeClient:
  896. """Connects instantly, download_to_file sleeps past wait_for's
  897. timeout then writes the file and returns True."""
  898. def __init__(self, *args, **kwargs):
  899. pass
  900. def connect(self):
  901. return True
  902. def download_to_file(self, remote_path, local_path, **_kwargs):
  903. time.sleep(0.4) # longer than wait_for timeout=0.1
  904. local_path.write_bytes(expected_content)
  905. return True
  906. def disconnect(self):
  907. pass
  908. monkeypatch.setattr(bambu_ftp, "BambuFTPClient", FakeClient)
  909. monkeypatch.setattr(FakeClient, "_mode_cache", {}, raising=False)
  910. monkeypatch.setattr(FakeClient, "A1_MODELS", {"A1"}, raising=False)
  911. def _noop_cache(ip, mode):
  912. pass
  913. monkeypatch.setattr(FakeClient, "cache_mode", staticmethod(_noop_cache), raising=False)
  914. result = await download_file_async(
  915. "127.0.0.1",
  916. "12345678",
  917. "/cache/zombie.bin",
  918. local,
  919. timeout=0.1,
  920. printer_model="X1C",
  921. )
  922. assert result is True
  923. assert local.read_bytes() == expected_content
  924. @pytest.mark.asyncio
  925. async def test_download_file_async_timeout_no_salvage_when_incomplete(self, tmp_path, monkeypatch):
  926. """Timeout returns False when thread has not signalled success.
  927. A partial file on disk (mid-retrbinary) must NOT be mistaken for a
  928. completed download — only the thread's explicit success flag permits
  929. salvage.
  930. """
  931. from backend.app.services import bambu_ftp
  932. bambu_ftp.BambuFTPClient._mode_cache.pop("127.0.0.1", None)
  933. local = tmp_path / "partial.bin"
  934. class FakeClient:
  935. def __init__(self, *args, **kwargs):
  936. pass
  937. def connect(self):
  938. return True
  939. def download_to_file(self, remote_path, local_path, **kwargs):
  940. # Simulate an in-progress partial write that never completes
  941. # until the async timeout asks the worker to unwind.
  942. local_path.write_bytes(b"partial...")
  943. while not kwargs["cancel_event"].is_set():
  944. time.sleep(0.01)
  945. local_path.unlink(missing_ok=True)
  946. raise bambu_ftp.DownloadCancelled(remote_path)
  947. def disconnect(self):
  948. pass
  949. monkeypatch.setattr(bambu_ftp, "BambuFTPClient", FakeClient)
  950. monkeypatch.setattr(FakeClient, "_mode_cache", {}, raising=False)
  951. monkeypatch.setattr(FakeClient, "A1_MODELS", set(), raising=False)
  952. monkeypatch.setattr(FakeClient, "cache_mode", staticmethod(lambda ip, mode: None), raising=False)
  953. result = await download_file_async(
  954. "127.0.0.1",
  955. "12345678",
  956. "/cache/partial.bin",
  957. local,
  958. timeout=0.1,
  959. printer_model="X1C",
  960. )
  961. assert result is False
  962. @pytest.mark.asyncio
  963. async def test_download_file_async_timeout_waits_for_slow_zombie(self, tmp_path, monkeypatch):
  964. """A zombie that completes within the 30s grace window is salvaged.
  965. Regression for #1014: on slow WiFi, download_to_file can overshoot the
  966. user's ftp_timeout by 10–30 s without being stuck. The old fixed 0.5 s
  967. post-timeout sleep was too short — it gave up and started attempt 2
  968. while attempt 1's zombie thread kept running, and by the time the zombie
  969. wrote the file to disk with a success flag, attempt 2 had already
  970. reported failure (its own completion dict was still False). The async
  971. wrapper now waits up to min(timeout, 30 s) for the worker thread to
  972. finish before returning, so a slow-but-progressing download salvages.
  973. """
  974. from backend.app.services import bambu_ftp
  975. bambu_ftp.BambuFTPClient._mode_cache.pop("127.0.0.1", None)
  976. local = tmp_path / "slow_zombie.bin"
  977. expected_content = b"finished during grace window"
  978. class FakeClient:
  979. """Mimics a slow FTP: wait_for gives up at 1.0 s but RETR takes
  980. 1.5 s total. Old 0.5 s fixed sleep would have bailed (0.5 < 0.5
  981. extra); new grace = max(min(1.0, 30), 0.5) = 1.0 s covers the
  982. remaining 0.5 s so salvage succeeds."""
  983. def __init__(self, *args, **kwargs):
  984. pass
  985. def connect(self):
  986. return True
  987. def download_to_file(self, remote_path, local_path, **_kwargs):
  988. time.sleep(1.5) # wait_for times out at 1.0 s; zombie finishes 0.5 s later
  989. local_path.write_bytes(expected_content)
  990. return True
  991. def disconnect(self):
  992. pass
  993. monkeypatch.setattr(bambu_ftp, "BambuFTPClient", FakeClient)
  994. monkeypatch.setattr(FakeClient, "_mode_cache", {}, raising=False)
  995. monkeypatch.setattr(FakeClient, "A1_MODELS", set(), raising=False)
  996. monkeypatch.setattr(FakeClient, "cache_mode", staticmethod(lambda ip, mode: None), raising=False)
  997. result = await download_file_async(
  998. "127.0.0.1",
  999. "12345678",
  1000. "/cache/slow_zombie.bin",
  1001. local,
  1002. timeout=1.0,
  1003. printer_model="X1C",
  1004. )
  1005. assert result is True
  1006. assert local.read_bytes() == expected_content
  1007. @pytest.mark.asyncio
  1008. async def test_download_file_async_cancellation_waits_for_worker_cleanup(self, tmp_path, monkeypatch):
  1009. """Task cancellation returns only after the FTP worker has unwound."""
  1010. from backend.app.services import bambu_ftp
  1011. bambu_ftp.BambuFTPClient._mode_cache.pop("127.0.0.1", None)
  1012. local = tmp_path / "cancelled.bin"
  1013. started = threading.Event()
  1014. finished = threading.Event()
  1015. class FakeClient:
  1016. def __init__(self, *args, **kwargs):
  1017. pass
  1018. def connect(self):
  1019. return True
  1020. def download_to_file(self, remote_path, local_path, **kwargs):
  1021. local_path.write_bytes(b"partial")
  1022. started.set()
  1023. try:
  1024. while not kwargs["cancel_event"].is_set():
  1025. time.sleep(0.01)
  1026. local_path.unlink(missing_ok=True)
  1027. raise bambu_ftp.DownloadCancelled(remote_path)
  1028. finally:
  1029. finished.set()
  1030. def disconnect(self):
  1031. pass
  1032. monkeypatch.setattr(bambu_ftp, "BambuFTPClient", FakeClient)
  1033. monkeypatch.setattr(FakeClient, "_mode_cache", {}, raising=False)
  1034. monkeypatch.setattr(FakeClient, "A1_MODELS", set(), raising=False)
  1035. monkeypatch.setattr(FakeClient, "cache_mode", staticmethod(lambda ip, mode: None), raising=False)
  1036. task = asyncio.create_task(
  1037. download_file_async(
  1038. "127.0.0.1",
  1039. "12345678",
  1040. "/cache/cancelled.bin",
  1041. local,
  1042. timeout=30.0,
  1043. printer_model="X1C",
  1044. )
  1045. )
  1046. assert await asyncio.to_thread(started.wait, 1.0)
  1047. task.cancel()
  1048. with pytest.raises(asyncio.CancelledError):
  1049. await task
  1050. assert finished.is_set()
  1051. assert not local.exists()
  1052. @pytest.mark.asyncio
  1053. async def test_download_file_try_paths_first_succeeds(self, patch_ftp_port, tmp_path):
  1054. """download_file_try_paths_async succeeds on first path."""
  1055. server = patch_ftp_port
  1056. server.add_file("cache/try1.bin", b"first path")
  1057. local = tmp_path / "try.bin"
  1058. result = await download_file_try_paths_async(
  1059. "127.0.0.1",
  1060. "12345678",
  1061. ["/cache/try1.bin", "/cache/try2.bin"],
  1062. local,
  1063. printer_model="X1C",
  1064. )
  1065. # The path, not a flag: the caller logs which candidate served the file
  1066. # so a stale same-named copy is diagnosable (#1820).
  1067. assert result == "/cache/try1.bin"
  1068. assert local.read_bytes() == b"first path"
  1069. @pytest.mark.asyncio
  1070. async def test_download_file_try_paths_fallback(self, patch_ftp_port, tmp_path):
  1071. """download_file_try_paths_async falls back to second path."""
  1072. server = patch_ftp_port
  1073. server.add_file("cache/second.bin", b"second path")
  1074. local = tmp_path / "fallback.bin"
  1075. result = await download_file_try_paths_async(
  1076. "127.0.0.1",
  1077. "12345678",
  1078. ["/cache/missing.bin", "/cache/second.bin"],
  1079. local,
  1080. printer_model="X1C",
  1081. )
  1082. assert result == "/cache/second.bin"
  1083. assert local.read_bytes() == b"second path"
  1084. @pytest.mark.asyncio
  1085. async def test_list_files_async_success(self, patch_ftp_port):
  1086. """list_files_async returns file list."""
  1087. server = patch_ftp_port
  1088. server.add_file("cache/listed.bin", b"data")
  1089. result = await list_files_async(
  1090. "127.0.0.1",
  1091. "12345678",
  1092. "/cache",
  1093. timeout=30.0,
  1094. printer_model="X1C",
  1095. )
  1096. names = {f["name"] for f in result}
  1097. assert "listed.bin" in names
  1098. @pytest.mark.asyncio
  1099. async def test_delete_file_async_success(self, patch_ftp_port):
  1100. """delete_file_async deletes a file."""
  1101. from backend.app.services.bambu_ftp import DeleteResult
  1102. server = patch_ftp_port
  1103. server.add_file("cache/to_async_del.bin", b"delete me")
  1104. result = await delete_file_async(
  1105. "127.0.0.1",
  1106. "12345678",
  1107. "/cache/to_async_del.bin",
  1108. printer_model="X1C",
  1109. )
  1110. assert result == DeleteResult.DELETED
  1111. assert not server.file_exists("cache/to_async_del.bin")
  1112. @pytest.mark.asyncio
  1113. async def test_delete_file_async_not_found(self, patch_ftp_port):
  1114. """delete_file_async distinguishes 550 from real failure (#1721)."""
  1115. from backend.app.services.bambu_ftp import DeleteResult
  1116. result = await delete_file_async(
  1117. "127.0.0.1",
  1118. "12345678",
  1119. "/cache/never_existed.bin",
  1120. printer_model="X1C",
  1121. )
  1122. assert result == DeleteResult.NOT_FOUND
  1123. # ---------------------------------------------------------------------------
  1124. # TestFailureScenarios
  1125. # ---------------------------------------------------------------------------
  1126. class TestFailureScenarios:
  1127. """Regression tests for known FTP failure modes."""
  1128. def test_550_caught_by_broad_except(self, ftp_client_factory, ftp_server, tmp_path):
  1129. """550 error_perm is caught by (OSError, ftplib.Error) handler.
  1130. Regression: error_perm is a subclass of ftplib.Error, so the
  1131. broad except clause in upload_file catches it correctly.
  1132. """
  1133. ftp_server.inject_failure("STOR", 550, "Permission denied.")
  1134. local = tmp_path / "test.bin"
  1135. local.write_bytes(b"data")
  1136. client = ftp_client_factory()
  1137. client.connect()
  1138. result = client.upload_file(local, "/cache/test.bin")
  1139. assert result is False
  1140. client.disconnect()
  1141. def test_zero_byte_download_detected(self, ftp_client_factory, ftp_server, tmp_path):
  1142. """0-byte download is detected and file is cleaned up.
  1143. Regression: Prior to fix, 0-byte downloads were reported as success.
  1144. """
  1145. ftp_server.add_file("cache/zero.bin", b"")
  1146. local = tmp_path / "zero.bin"
  1147. client = ftp_client_factory()
  1148. client.connect()
  1149. result = client.download_to_file("/cache/zero.bin", local)
  1150. assert result is False
  1151. assert not local.exists()
  1152. client.disconnect()
  1153. def test_connection_refused_handled(self):
  1154. """Connection refused is handled gracefully."""
  1155. client = BambuFTPClient("127.0.0.1", "12345678", timeout=2.0)
  1156. client.FTP_PORT = 1 # Almost certainly not listening
  1157. assert client.connect() is False
  1158. def test_auth_failure_530(self, ftp_client_factory, ftp_server):
  1159. """530 authentication failure returns False."""
  1160. ftp_server.inject_failure("PASS", 530, "Login incorrect.")
  1161. client = ftp_client_factory()
  1162. result = client.connect()
  1163. assert result is False
  1164. def test_retr_550_handled(self, ftp_client_factory, ftp_server):
  1165. """RETR 550 (file not found) returns None."""
  1166. ftp_server.inject_failure("RETR", 550, "File not found.")
  1167. ftp_server.add_file("cache/exists.bin", b"data")
  1168. client = ftp_client_factory()
  1169. client.connect()
  1170. result = client.download_file("/cache/exists.bin")
  1171. assert result is None
  1172. client.disconnect()
  1173. def test_cwd_550_handled(self, ftp_client_factory, ftp_server):
  1174. """CWD 550 is handled in list_files."""
  1175. ftp_server.inject_failure("CWD", 550, "Directory not found.")
  1176. client = ftp_client_factory()
  1177. client.connect()
  1178. result = client.list_files("/nonexistent")
  1179. assert result == []
  1180. client.disconnect()
  1181. def test_stor_553_handled(self, ftp_client_factory, ftp_server, tmp_path):
  1182. """STOR 553 (no SD card) handled gracefully."""
  1183. ftp_server.inject_failure("STOR", 553, "Could not create file.")
  1184. local = tmp_path / "test.bin"
  1185. local.write_bytes(b"test")
  1186. client = ftp_client_factory()
  1187. client.connect()
  1188. result = client.upload_file(local, "/cache/test.bin")
  1189. assert result is False
  1190. client.disconnect()
  1191. def test_diagnose_storage_cwd_failure_doesnt_propagate(self, ftp_client_factory, ftp_server):
  1192. """diagnose_storage CWD failure doesn't crash the whole operation.
  1193. Regression: diagnose_storage() was called in the upload path and
  1194. a CWD failure would propagate and crash the upload.
  1195. """
  1196. ftp_server.inject_failure("CWD", 550, "No such directory.", count=2)
  1197. client = ftp_client_factory()
  1198. client.connect()
  1199. diag = client.diagnose_storage()
  1200. # Should still return results (with errors noted)
  1201. assert diag["connected"] is True
  1202. assert len(diag["errors"]) > 0
  1203. client.disconnect()
  1204. def test_failure_injection_count_decrements(self, ftp_client_factory, ftp_server):
  1205. """Failure injection with count decrements and eventually succeeds."""
  1206. ftp_server.add_file("cache/retry.bin", b"data after retry")
  1207. ftp_server.inject_failure("RETR", 550, "Temporary error.", count=1)
  1208. client = ftp_client_factory()
  1209. client.connect()
  1210. # First attempt fails
  1211. result1 = client.download_file("/cache/retry.bin")
  1212. assert result1 is None
  1213. # Second attempt succeeds (failure count exhausted)
  1214. result2 = client.download_file("/cache/retry.bin")
  1215. assert result2 == b"data after retry"
  1216. client.disconnect()
  1217. def test_upload_skips_voidresp(self, ftp_client_factory, ftp_server, tmp_path):
  1218. """Upload returns True without calling voidresp() for any model.
  1219. voidresp() is skipped for all models: A1 printers hang on it,
  1220. H2D printers delay the 226 response by 30+ seconds, and X1C/P1S
  1221. gain nothing from waiting. The file is on the SD card once
  1222. sendall() returns.
  1223. """
  1224. content = b"voidresp test data"
  1225. local = tmp_path / "voidresp_test.3mf"
  1226. local.write_bytes(content)
  1227. for model in ("X1C", "A1", "H2D", None):
  1228. client = ftp_client_factory(printer_model=model)
  1229. client.connect()
  1230. result = client.upload_file(local, "/cache/voidresp_test.3mf")
  1231. assert result is True, f"Upload failed for model={model}"
  1232. client.disconnect()
  1233. # Verify the file is actually on the server
  1234. time.sleep(_UPLOAD_FLUSH_DELAY)
  1235. client2 = ftp_client_factory()
  1236. client2.connect()
  1237. downloaded = client2.download_file("/cache/voidresp_test.3mf")
  1238. assert downloaded == content, f"Content mismatch for model={model}"
  1239. client2.disconnect()
  1240. # ---------------------------------------------------------------------------
  1241. # Short-circuit retries on 550 (#972)
  1242. # ---------------------------------------------------------------------------
  1243. class TestFileNotOnPrinterShortCircuit:
  1244. """FileNotOnPrinterError must bypass the retry budget.
  1245. Before this fix, a 3MF path that wasn't on the printer (550) cost
  1246. `ftp_retry_count + 1` attempts × `ftp_retry_delay` seconds per candidate
  1247. path. With ftp_retry_count=10 and four candidate paths, that's ~22 min
  1248. of dead retries before the real path is tried. #972 in the wild showed
  1249. 48 min of retrying paths that didn't exist.
  1250. """
  1251. async def test_with_ftp_retry_propagates_file_not_on_printer_without_retrying(self):
  1252. """with_ftp_retry raises FileNotOnPrinterError on first attempt.
  1253. Verifies non_retry_exceptions short-circuits before the retry loop
  1254. has a chance to sleep and try again.
  1255. """
  1256. attempts = {"n": 0}
  1257. async def always_missing(*_args, **_kwargs):
  1258. attempts["n"] += 1
  1259. raise FileNotOnPrinterError("/cache/absent.3mf: 550")
  1260. with pytest.raises(FileNotOnPrinterError):
  1261. await with_ftp_retry(
  1262. always_missing,
  1263. max_retries=10,
  1264. retry_delay=0.01,
  1265. operation_name="test 550 short-circuit",
  1266. non_retry_exceptions=(FileNotOnPrinterError,),
  1267. )
  1268. assert attempts["n"] == 1, "550 must not trigger any retry"
  1269. async def test_with_ftp_retry_still_retries_transient_errors(self):
  1270. """Non-550 exceptions continue to retry up to max_retries + 1."""
  1271. attempts = {"n": 0}
  1272. async def flaky(*_args, **_kwargs):
  1273. attempts["n"] += 1
  1274. raise TimeoutError("transient")
  1275. result = await with_ftp_retry(
  1276. flaky,
  1277. max_retries=2,
  1278. retry_delay=0.01,
  1279. operation_name="test transient retries",
  1280. non_retry_exceptions=(FileNotOnPrinterError,),
  1281. )
  1282. assert result is None
  1283. assert attempts["n"] == 3, "Transient errors should retry to exhaustion"
  1284. def test_download_to_file_raises_on_missing_path(self, ftp_client_factory, tmp_path):
  1285. """download_to_file surfaces 550 as FileNotOnPrinterError end-to-end
  1286. against the real mock FTPS server, not just a hand-rolled mock."""
  1287. local = tmp_path / "never_downloaded.3mf"
  1288. client = ftp_client_factory()
  1289. client.connect()
  1290. try:
  1291. with pytest.raises(FileNotOnPrinterError):
  1292. client.download_to_file("/cache/does_not_exist.3mf", local)
  1293. finally:
  1294. client.disconnect()
  1295. assert not local.exists(), "Partial file must be cleaned up on 550"
  1296. # ---------------------------------------------------------------------------
  1297. # 3MF download cache (#972)
  1298. # ---------------------------------------------------------------------------
  1299. class TestThreeMFCache:
  1300. """Cover endpoint and archive flow share downloaded 3MF bytes via this
  1301. cache. Tests isolate themselves with clear_3mf_cache(delete_files=False)
  1302. so they don't clobber each other."""
  1303. def setup_method(self):
  1304. clear_3mf_cache(delete_files=False)
  1305. def teardown_method(self):
  1306. clear_3mf_cache(delete_files=False)
  1307. def test_normalize_collapses_filename_variants(self):
  1308. """Bambu names vary (.3mf, .gcode.3mf, with spaces) — they all map
  1309. to the same cache slot so both flows agree on the key."""
  1310. canonical = normalize_3mf_name("Broly_Legendary.gcode.3mf")
  1311. assert normalize_3mf_name("Broly_Legendary.3mf") == canonical
  1312. assert normalize_3mf_name("Broly_Legendary") == canonical
  1313. # Bambu Studio rewrites spaces to underscores on upload — treat as equal
  1314. assert normalize_3mf_name("Broly Legendary") == canonical
  1315. # Case is also collapsed so keys match across capitalizations
  1316. assert normalize_3mf_name("BROLY_LEGENDARY.3MF") == canonical
  1317. def test_cache_hit_returns_stored_path(self, tmp_path):
  1318. """get_cached_3mf returns the same Path that was put in."""
  1319. f = tmp_path / "Broly.gcode.3mf"
  1320. f.write_bytes(b"fake 3mf content")
  1321. cache_3mf_download(1, "Broly.gcode.3mf", f)
  1322. assert get_cached_3mf(1, "Broly.gcode.3mf") == f
  1323. def test_cache_lookup_uses_normalized_name(self, tmp_path):
  1324. """Caching under .gcode.3mf and querying with bare name still hits."""
  1325. f = tmp_path / "Broly.gcode.3mf"
  1326. f.write_bytes(b"x")
  1327. cache_3mf_download(1, "Broly.gcode.3mf", f)
  1328. assert get_cached_3mf(1, "Broly.3mf") == f
  1329. assert get_cached_3mf(1, "Broly") == f
  1330. def test_cache_miss_on_different_printer(self, tmp_path):
  1331. """Printer id is part of the key — two printers never collide."""
  1332. f = tmp_path / "A.3mf"
  1333. f.write_bytes(b"x")
  1334. cache_3mf_download(1, "A.3mf", f)
  1335. assert get_cached_3mf(2, "A.3mf") is None
  1336. def test_cache_evicts_when_file_deleted(self, tmp_path):
  1337. """Stale entry (file gone) returns None and is dropped from the dict."""
  1338. f = tmp_path / "A.3mf"
  1339. f.write_bytes(b"x")
  1340. cache_3mf_download(1, "A.3mf", f)
  1341. f.unlink()
  1342. assert get_cached_3mf(1, "A.3mf") is None
  1343. # Re-populating after eviction works — no ghost entries remain.
  1344. f.write_bytes(b"y")
  1345. cache_3mf_download(1, "A.3mf", f)
  1346. assert get_cached_3mf(1, "A.3mf") == f
  1347. def test_clear_by_printer_scoped(self, tmp_path, monkeypatch):
  1348. """Clearing one printer leaves the other untouched."""
  1349. from backend.app.core import config as _config
  1350. monkeypatch.setattr(_config.settings, "archive_dir", tmp_path)
  1351. temp_dir = tmp_path / "temp"
  1352. temp_dir.mkdir()
  1353. f1 = temp_dir / "one.3mf"
  1354. f1.write_bytes(b"1")
  1355. f2 = temp_dir / "two.3mf"
  1356. f2.write_bytes(b"2")
  1357. cache_3mf_download(1, "one.3mf", f1)
  1358. cache_3mf_download(2, "two.3mf", f2)
  1359. clear_3mf_cache(1)
  1360. assert get_cached_3mf(1, "one.3mf") is None
  1361. assert get_cached_3mf(2, "two.3mf") == f2
  1362. # clear_3mf_cache defaulted to delete_files=True, so the temp file is gone
  1363. assert not f1.exists()
  1364. assert f2.exists()
  1365. def test_clear_without_deleting_files(self, tmp_path, monkeypatch):
  1366. """delete_files=False leaves files on disk — used by tests."""
  1367. from backend.app.core import config as _config
  1368. monkeypatch.setattr(_config.settings, "archive_dir", tmp_path)
  1369. temp_dir = tmp_path / "temp"
  1370. temp_dir.mkdir()
  1371. f = temp_dir / "keep.3mf"
  1372. f.write_bytes(b"x")
  1373. cache_3mf_download(1, "keep.3mf", f)
  1374. clear_3mf_cache(1, delete_files=False)
  1375. assert get_cached_3mf(1, "keep.3mf") is None
  1376. assert f.exists()
  1377. def test_clear_does_not_delete_persistent_files(self, tmp_path, monkeypatch):
  1378. """Regression for #1212 / "file disappeared overnight" reports.
  1379. Dispatch sites added in #1166 cache the live archive copy and library
  1380. file bytes — paths outside ``archive_dir/temp`` — so /cover can skip
  1381. FTP. Those files are user data; the cache cleanup must never unlink
  1382. them. Pre-fix, ``clear_3mf_cache(printer_id, delete_files=True)`` ran
  1383. on every ``on_print_complete`` and silently destroyed them, leaving a
  1384. DB row whose ``file_path`` pointed at nothing — breaking Reprint and
  1385. View G-code with a 404.
  1386. """
  1387. from backend.app.core import config as _config
  1388. monkeypatch.setattr(_config.settings, "archive_dir", tmp_path / "archive")
  1389. (tmp_path / "archive" / "temp").mkdir(parents=True)
  1390. archive_file = tmp_path / "archive" / "1" / "20260504_wallhooks" / "wallhooks.gcode.3mf"
  1391. archive_file.parent.mkdir(parents=True)
  1392. archive_file.write_bytes(b"archive bytes")
  1393. library_file = tmp_path / "library_files" / "abcd.3mf"
  1394. library_file.parent.mkdir(parents=True)
  1395. library_file.write_bytes(b"library bytes")
  1396. temp_file = tmp_path / "archive" / "temp" / "cover_1_x.3mf"
  1397. temp_file.write_bytes(b"temp bytes")
  1398. cache_3mf_download(1, "wallhooks.gcode.3mf", archive_file)
  1399. cache_3mf_download(1, "library.3mf", library_file)
  1400. cache_3mf_download(1, "cover_1_x.3mf", temp_file)
  1401. clear_3mf_cache(1)
  1402. # All three cache entries are dropped from the dict.
  1403. assert get_cached_3mf(1, "wallhooks.gcode.3mf") is None
  1404. assert get_cached_3mf(1, "library.3mf") is None
  1405. assert get_cached_3mf(1, "cover_1_x.3mf") is None
  1406. # But only the temp file is unlinked — user data survives.
  1407. assert archive_file.exists(), "archive 3mf must not be deleted by cache cleanup"
  1408. assert library_file.exists(), "library 3mf must not be deleted by cache cleanup"
  1409. assert not temp_file.exists(), "temp file should still be cleaned up"
  1410. @pytest.fixture
  1411. def slow_upload_client(monkeypatch):
  1412. """Replace BambuFTPClient with a fake whose upload streams slowly.
  1413. Mirrors the real client's contract for the bits that matter here: it fires
  1414. the progress callback once per chunk and treats a callback exception as
  1415. "stop now" — break out of the send loop, drop the partial file, re-raise.
  1416. The returned dict lets a test see what the worker thread actually did,
  1417. which is the whole point: the #2529 ghost transfer was invisible from the
  1418. event loop's side.
  1419. """
  1420. state = {
  1421. "attempts": 0,
  1422. "concurrent": 0,
  1423. "max_concurrent": 0,
  1424. "completed": False,
  1425. "cancelled": False,
  1426. "deleted": [],
  1427. "chunks": 20,
  1428. "chunk_delay": 0.05,
  1429. }
  1430. lock = threading.Lock()
  1431. class FakeClient:
  1432. def __init__(self, *args, **kwargs):
  1433. pass
  1434. def connect(self):
  1435. return True
  1436. def upload_file(self, local_path, remote_path, progress_callback=None):
  1437. with lock:
  1438. state["attempts"] += 1
  1439. state["concurrent"] += 1
  1440. state["max_concurrent"] = max(state["max_concurrent"], state["concurrent"])
  1441. try:
  1442. total = state["chunks"]
  1443. for sent in range(1, total + 1):
  1444. time.sleep(state["chunk_delay"])
  1445. if progress_callback:
  1446. try:
  1447. progress_callback(sent, total)
  1448. except Exception:
  1449. state["cancelled"] = True
  1450. state["deleted"].append(remote_path)
  1451. raise
  1452. state["completed"] = True
  1453. return True
  1454. finally:
  1455. with lock:
  1456. state["concurrent"] -= 1
  1457. def disconnect(self):
  1458. pass
  1459. monkeypatch.setattr(bambu_ftp, "BambuFTPClient", FakeClient)
  1460. monkeypatch.setattr(FakeClient, "_mode_cache", {}, raising=False)
  1461. monkeypatch.setattr(FakeClient, "A1_MODELS", ("A1", "A1 Mini"), raising=False)
  1462. monkeypatch.setattr(FakeClient, "cache_mode", staticmethod(lambda ip, mode: None), raising=False)
  1463. return state
  1464. # ---------------------------------------------------------------------------
  1465. # TestUploadDeadline (#2529)
  1466. # ---------------------------------------------------------------------------
  1467. class TestUploadDeadline:
  1468. """The upload deadline must be size-aware, and must actually stop the transfer.
  1469. Regression for #2529: a 96 MB 3MF to an A1 over WiFi sustains ~75 KB/s and
  1470. needs ~20 minutes. The old flat 600 s wall-clock cap declared it dead at
  1471. ~70 MB, `asyncio.wait_for` cancelled the *future* but not the executor
  1472. thread — which kept streaming — and `with_ftp_retry` then started a second
  1473. STOR of the same file onto the same printer. The reporter's video shows two
  1474. transfers of the same job climbing in parallel (2% and 72%), and the print
  1475. never landed.
  1476. """
  1477. def test_deadline_scales_with_file_size(self, tmp_path):
  1478. """A big file gets proportionally longer, a small one gets the floor."""
  1479. small = tmp_path / "small.3mf"
  1480. small.write_bytes(b"x" * 1024)
  1481. assert bambu_ftp._upload_deadline(small) == bambu_ftp._UPLOAD_MIN_TIMEOUT
  1482. # The reporter's file. At the 25 KB/s floor rate, 96 MB is ~64 minutes —
  1483. # far above the 600 s that killed it at 72%.
  1484. big = tmp_path / "big.3mf"
  1485. big.write_bytes(b"x" * (96 * 1024 * 1024))
  1486. deadline = bambu_ftp._upload_deadline(big)
  1487. assert deadline > bambu_ftp._UPLOAD_MIN_TIMEOUT
  1488. assert deadline == pytest.approx((96 * 1024 * 1024) / bambu_ftp._UPLOAD_FLOOR_BYTES_PER_SEC)
  1489. def test_deadline_falls_back_to_floor_for_unstatable_file(self, tmp_path):
  1490. assert bambu_ftp._upload_deadline(tmp_path / "nope.3mf") == bambu_ftp._UPLOAD_MIN_TIMEOUT
  1491. @pytest.mark.asyncio
  1492. async def test_timeout_stops_the_worker_thread(self, tmp_path, monkeypatch, slow_upload_client):
  1493. """The transfer stops when the deadline expires, instead of streaming on.
  1494. Mutation check: drop the `cancel.set()` in upload_file_async and the
  1495. worker runs to completion, which is exactly the ghost transfer #2529
  1496. reported.
  1497. """
  1498. state = slow_upload_client
  1499. local = tmp_path / "slow.3mf"
  1500. local.write_bytes(b"x" * 4096)
  1501. with pytest.raises(bambu_ftp.UploadCancelled):
  1502. await upload_file_async("127.0.0.1", "12345678", local, "/cache/slow.3mf", timeout=0.2, printer_model="X1C")
  1503. # The worker noticed the cancel and unwound — it did not run to the end.
  1504. await asyncio.sleep(0.5)
  1505. assert state["cancelled"] is True
  1506. assert state["completed"] is False
  1507. # And it cleaned the partial file off the printer on its way out.
  1508. assert state["deleted"] == ["/cache/slow.3mf"]
  1509. @pytest.mark.asyncio
  1510. async def test_timeout_is_not_retried(self, tmp_path, monkeypatch, slow_upload_client):
  1511. """with_ftp_retry must not start a second transfer after a deadline expiry.
  1512. This is the bug the reporter filmed: attempt 2 began while attempt 1 was
  1513. still sending. One attempt, then a hard failure.
  1514. """
  1515. state = slow_upload_client
  1516. local = tmp_path / "slow.3mf"
  1517. local.write_bytes(b"x" * 4096)
  1518. with pytest.raises(bambu_ftp.UploadCancelled):
  1519. await with_ftp_retry(
  1520. upload_file_async,
  1521. "127.0.0.1",
  1522. "12345678",
  1523. local,
  1524. "/cache/slow.3mf",
  1525. timeout=0.2,
  1526. printer_model="X1C",
  1527. max_retries=3,
  1528. retry_delay=0,
  1529. )
  1530. assert state["attempts"] == 1, "a timed-out upload must not be retried"
  1531. @pytest.mark.asyncio
  1532. async def test_uploads_to_one_printer_are_serialized(self, tmp_path, monkeypatch, slow_upload_client):
  1533. """Two dispatches to the same printer queue up; they never overlap.
  1534. Concurrent STORs of the same remote path leave a corrupt file on the SD
  1535. card and make the printer look like it has a flaky network.
  1536. """
  1537. state = slow_upload_client
  1538. state["chunk_delay"] = 0.05
  1539. local = tmp_path / "slow.3mf"
  1540. local.write_bytes(b"x" * 4096)
  1541. async def _dispatch(name: str) -> bool:
  1542. return await upload_file_async(
  1543. "127.0.0.1", "12345678", local, f"/cache/{name}.3mf", timeout=30.0, printer_model="X1C"
  1544. )
  1545. results = await asyncio.gather(_dispatch("a"), _dispatch("b"))
  1546. assert results == [True, True]
  1547. assert state["attempts"] == 2
  1548. assert state["max_concurrent"] == 1, "two uploads ran against the same printer at once"
  1549. def test_progress_callback_raising_deletes_the_partial_file(self, ftp_client_factory, ftp_root, tmp_path):
  1550. """The cancel path in the real client removes what it already wrote.
  1551. This is the mechanism the deadline now hangs off, exercised end to end
  1552. against the mock FTPS server rather than a fake.
  1553. """
  1554. client = ftp_client_factory()
  1555. assert client.connect() is True
  1556. try:
  1557. local = tmp_path / "cancelme.3mf"
  1558. # Two chunks, so the callback fires while there is a partial file.
  1559. local.write_bytes(b"x" * (BambuFTPClient.CHUNK_SIZE * 2))
  1560. def _stop_after_first_chunk(uploaded: int, total: int) -> None:
  1561. raise bambu_ftp.UploadCancelled("stop")
  1562. with pytest.raises(bambu_ftp.UploadCancelled):
  1563. client.upload_file(local, "/cancelme.3mf", _stop_after_first_chunk)
  1564. finally:
  1565. client.disconnect()
  1566. time.sleep(_UPLOAD_FLUSH_DELAY)
  1567. assert not (Path(ftp_root) / "cancelme.3mf").exists(), "partial file left on the printer"
  1568. # ---------------------------------------------------------------------------
  1569. # TestHandshakeCoolOff
  1570. # ---------------------------------------------------------------------------
  1571. class _PlaintextServer:
  1572. """A socket that accepts on 990 and answers in plaintext, not TLS.
  1573. This is what #2780's printers do once their file service wedges: the TCP
  1574. connect succeeds, so a port probe reports the printer as healthy, and then
  1575. the implicit-FTPS handshake dies on ``WRONG_VERSION_NUMBER`` because the
  1576. first bytes back are an FTP banner rather than a TLS record.
  1577. """
  1578. def __init__(self):
  1579. self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  1580. self._sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
  1581. self._sock.bind(("127.0.0.1", 0))
  1582. self._sock.listen(8)
  1583. self.port = self._sock.getsockname()[1]
  1584. self.accepts = 0
  1585. self._stop = threading.Event()
  1586. self._thread = threading.Thread(target=self._serve, daemon=True)
  1587. self._thread.start()
  1588. def _serve(self):
  1589. while not self._stop.is_set():
  1590. try:
  1591. conn, _addr = self._sock.accept()
  1592. except OSError:
  1593. return
  1594. self.accepts += 1
  1595. try:
  1596. conn.sendall(b"220 Welcome to the printer.\r\n")
  1597. except OSError:
  1598. pass
  1599. finally:
  1600. conn.close()
  1601. def stop(self):
  1602. self._stop.set()
  1603. self._sock.close()
  1604. self._thread.join(timeout=2)
  1605. @pytest.fixture()
  1606. def plaintext_server():
  1607. server = _PlaintextServer()
  1608. yield server
  1609. server.stop()
  1610. class TestHandshakeCoolOff:
  1611. """A wedged file service must be contacted once, not hundreds of times.
  1612. #2780: an X2D served clean FTPS for five days, flipped, and then failed
  1613. every handshake for eight more. Because each candidate path opened its own
  1614. connection, one reporter's log carried 3511 identical handshake failures.
  1615. """
  1616. def _client(self, server, ip="127.0.0.1"):
  1617. client = BambuFTPClient(ip, "12345678", timeout=5.0, printer_model="P2S")
  1618. client.FTP_PORT = server.port
  1619. return client
  1620. def test_plaintext_answer_on_990_blocks_the_printer(self, plaintext_server, caplog):
  1621. client = self._client(plaintext_server)
  1622. with caplog.at_level(logging.WARNING, logger="backend.app.services.bambu_ftp"):
  1623. assert client.connect() is False
  1624. assert bambu_ftp.ftps_handshake_blocked("127.0.0.1") is True
  1625. messages = [r.getMessage() for r in caplog.records]
  1626. assert any("WRONG_VERSION_NUMBER" in m for m in messages)
  1627. # The warning has to say more than the error: the port is open, so
  1628. # "unblock port 990" is the wrong reading and the operator needs to
  1629. # know what it actually costs them.
  1630. assert any("covers and timelapses cannot be fetched" in m for m in messages)
  1631. # It must NOT prescribe a restart. It used to, and #2780's reporter
  1632. # power-cycled both affected printers with no effect while a single
  1633. # manual connection to the same printers completed a clean handshake.
  1634. # Naming a remedy that is known not to work is worse than naming none.
  1635. assert not any("restart" in m.lower() for m in messages)
  1636. def test_blocked_printer_is_not_contacted_again(self, plaintext_server):
  1637. self._client(plaintext_server).connect()
  1638. # Two, not one: the failed handshake, then one cleartext read asking
  1639. # what the printer actually answered with (#2780). That read is the
  1640. # whole diagnosis and it happens once per cool-off, so the promise
  1641. # this test exists for -- contacted a couple of times, not ~110 --
  1642. # still holds.
  1643. assert plaintext_server.accepts == 2
  1644. for _ in range(5):
  1645. assert self._client(plaintext_server).connect() is False
  1646. # Still two: the cool-off answered without opening a socket, and it
  1647. # gates the probe as well as the handshake.
  1648. assert plaintext_server.accepts == 2
  1649. def test_cooloff_expiry_lets_the_printer_be_retried(self, plaintext_server, monkeypatch):
  1650. monkeypatch.setattr(bambu_ftp, "_HANDSHAKE_COOLOFF_SECONDS", 0.0)
  1651. self._client(plaintext_server).connect()
  1652. assert bambu_ftp.ftps_handshake_blocked("127.0.0.1") is False
  1653. assert self._client(plaintext_server).connect() is False
  1654. # Two handshakes and a cleartext probe on each. A zero-length cool-off
  1655. # is what makes the probe repeat -- it is gated on the window being
  1656. # already open, and here there is never a window. At the real 300s it
  1657. # runs once, which `test_blocked_printer_is_not_contacted_again` pins.
  1658. assert plaintext_server.accepts == 4
  1659. def test_block_is_per_printer(self, plaintext_server):
  1660. self._client(plaintext_server).connect()
  1661. assert bambu_ftp.ftps_handshake_blocked("127.0.0.1") is True
  1662. # A second printer that never failed must stay reachable.
  1663. assert bambu_ftp.ftps_handshake_blocked("192.0.2.77") is False
  1664. def test_expired_block_lets_a_recovered_printer_straight_back_in(self, ftp_client_factory):
  1665. """A power-cycled printer is picked up on the next print, not held out.
  1666. The expired entry is also dropped, so the map holds one key per
  1667. currently-wedged printer rather than one per printer this process has
  1668. ever failed against.
  1669. """
  1670. BambuFTPClient._handshake_blocked_until["127.0.0.1"] = time.monotonic() - 1
  1671. client = ftp_client_factory()
  1672. assert client.connect() is True
  1673. client.disconnect()
  1674. assert BambuFTPClient._handshake_blocked_until == {}