test_bambu_ftp.py 46 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167
  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 time
  15. from pathlib import Path
  16. import pytest
  17. from backend.app.services.bambu_ftp import (
  18. BambuFTPClient,
  19. FileNotOnPrinterError,
  20. cache_3mf_download,
  21. clear_3mf_cache,
  22. delete_file_async,
  23. download_file_async,
  24. download_file_try_paths_async,
  25. get_cached_3mf,
  26. list_files_async,
  27. normalize_3mf_name,
  28. upload_file_async,
  29. with_ftp_retry,
  30. )
  31. # Brief delay to allow pyftpdlib to flush uploaded files to disk.
  32. # Needed because upload_file() skips voidresp() for all models,
  33. # so the server may still be processing the data channel close event.
  34. _UPLOAD_FLUSH_DELAY = 0.3
  35. # ---------------------------------------------------------------------------
  36. # TestConnection
  37. # ---------------------------------------------------------------------------
  38. class TestConnection:
  39. """Tests for FTP connect/disconnect behavior."""
  40. def test_connect_success(self, ftp_client_factory):
  41. """Successful implicit FTPS connection and login."""
  42. client = ftp_client_factory()
  43. assert client.connect() is True
  44. client.disconnect()
  45. def test_connect_wrong_access_code(self, ftp_client_factory):
  46. """Wrong access code returns False."""
  47. client = ftp_client_factory(access_code="wrongcode")
  48. assert client.connect() is False
  49. def test_connect_unreachable_host(self, ftp_server):
  50. """Unreachable host returns False."""
  51. client = BambuFTPClient(
  52. ip_address="192.0.2.1", # TEST-NET, guaranteed unreachable
  53. access_code="12345678",
  54. timeout=1.0,
  55. printer_model="X1C",
  56. )
  57. client.FTP_PORT = ftp_server.port
  58. assert client.connect() is False
  59. def test_connect_timeout(self, ftp_server):
  60. """Very short timeout triggers timeout error."""
  61. client = BambuFTPClient(
  62. ip_address="192.0.2.1",
  63. access_code="12345678",
  64. timeout=0.001, # Extremely short
  65. printer_model="X1C",
  66. )
  67. client.FTP_PORT = ftp_server.port
  68. assert client.connect() is False
  69. def test_disconnect_clean(self, ftp_client_factory):
  70. """Clean disconnect after successful connect."""
  71. client = ftp_client_factory()
  72. client.connect()
  73. client.disconnect()
  74. assert client._ftp is None
  75. def test_disconnect_without_connect(self, ftp_client_factory):
  76. """Disconnect without connect does not raise."""
  77. client = ftp_client_factory()
  78. client.disconnect() # Should not raise
  79. assert client._ftp is None
  80. def test_x1c_uses_prot_p(self, ftp_client_factory):
  81. """X1C model connects with prot_p (protected data channel)."""
  82. client = ftp_client_factory(printer_model="X1C")
  83. assert client.connect() is True
  84. assert client._should_use_prot_c() is False
  85. client.disconnect()
  86. def test_a1_defaults_prot_p(self, ftp_client_factory):
  87. """A1 model defaults to prot_p when no cache exists."""
  88. client = ftp_client_factory(printer_model="A1")
  89. assert client._should_use_prot_c() is False
  90. assert client.connect() is True
  91. client.disconnect()
  92. def test_a1_force_prot_c(self, ftp_client_factory):
  93. """A1 model with force_prot_c uses clear data channel."""
  94. client = ftp_client_factory(printer_model="A1", force_prot_c=True)
  95. assert client._should_use_prot_c() is True
  96. assert client.connect() is True
  97. client.disconnect()
  98. def test_cached_mode_respected(self, ftp_client_factory):
  99. """Cached mode is used on subsequent connections."""
  100. BambuFTPClient.cache_mode("127.0.0.1", "prot_c")
  101. client = ftp_client_factory(printer_model="A1")
  102. assert client._should_use_prot_c() is True
  103. assert client.connect() is True
  104. client.disconnect()
  105. # ---------------------------------------------------------------------------
  106. # TestDisconnectServerGone — isolated class because server.stop() calls
  107. # close_all() which nukes all asyncore sockets globally.
  108. # ---------------------------------------------------------------------------
  109. class TestDisconnectServerGone:
  110. """Test disconnect behavior when the server has stopped."""
  111. def test_disconnect_after_server_gone(self, ftp_certs, tmp_path):
  112. """Disconnect after server has stopped does not raise.
  113. disconnect() catches OSError, ftplib.Error, and EOFError so that
  114. best-effort cleanup never propagates exceptions to the caller.
  115. """
  116. from backend.tests.unit.services.mock_ftp_server import (
  117. MockBambuFTPServer,
  118. )
  119. from .conftest import _find_free_port
  120. cert_path, key_path = ftp_certs
  121. port = _find_free_port()
  122. server = MockBambuFTPServer("127.0.0.1", port, str(tmp_path), cert_path, key_path)
  123. server.start()
  124. client = BambuFTPClient("127.0.0.1", "12345678", timeout=5.0)
  125. client.FTP_PORT = port
  126. client.connect()
  127. server.stop()
  128. # Should not raise — disconnect() catches all connection errors
  129. client.disconnect()
  130. assert client._ftp is None
  131. # ---------------------------------------------------------------------------
  132. # TestListFiles
  133. # ---------------------------------------------------------------------------
  134. class TestListFiles:
  135. """Tests for directory listing."""
  136. def test_list_empty_directory(self, ftp_client_factory):
  137. """Listing an empty directory returns empty list."""
  138. client = ftp_client_factory()
  139. client.connect()
  140. files = client.list_files("/cache")
  141. assert files == []
  142. client.disconnect()
  143. def test_list_directory_with_files(self, ftp_client_factory, ftp_server):
  144. """Files in directory are listed correctly."""
  145. ftp_server.add_file("cache/test.3mf", b"x" * 1024)
  146. ftp_server.add_file("cache/test2.gcode", b"y" * 512)
  147. client = ftp_client_factory()
  148. client.connect()
  149. files = client.list_files("/cache")
  150. names = {f["name"] for f in files}
  151. assert "test.3mf" in names
  152. assert "test2.gcode" in names
  153. client.disconnect()
  154. def test_directories_marked(self, ftp_client_factory, ftp_server):
  155. """Subdirectories are identified with is_directory=True."""
  156. ftp_server.add_directory("model/subdir")
  157. client = ftp_client_factory()
  158. client.connect()
  159. files = client.list_files("/model")
  160. dirs = [f for f in files if f["is_directory"]]
  161. assert len(dirs) >= 1
  162. assert dirs[0]["name"] == "subdir"
  163. client.disconnect()
  164. def test_nonexistent_path_returns_empty(self, ftp_client_factory):
  165. """Listing a nonexistent path returns empty list."""
  166. client = ftp_client_factory()
  167. client.connect()
  168. files = client.list_files("/nonexistent/path")
  169. assert files == []
  170. client.disconnect()
  171. def test_file_sizes_and_paths(self, ftp_client_factory, ftp_server):
  172. """File sizes and full paths are parsed correctly."""
  173. ftp_server.add_file("cache/sized.bin", b"a" * 2048)
  174. client = ftp_client_factory()
  175. client.connect()
  176. files = client.list_files("/cache")
  177. sized = [f for f in files if f["name"] == "sized.bin"]
  178. assert len(sized) == 1
  179. assert sized[0]["size"] == 2048
  180. assert sized[0]["path"] == "/cache/sized.bin"
  181. client.disconnect()
  182. # ---------------------------------------------------------------------------
  183. # TestDownload
  184. # ---------------------------------------------------------------------------
  185. class TestDownload:
  186. """Tests for file download operations."""
  187. def test_download_file_returns_bytes(self, ftp_client_factory, ftp_server):
  188. """download_file() returns file content as bytes."""
  189. content = b"Hello FTP World!"
  190. ftp_server.add_file("cache/hello.txt", content)
  191. client = ftp_client_factory()
  192. client.connect()
  193. result = client.download_file("/cache/hello.txt")
  194. assert result == content
  195. client.disconnect()
  196. def test_download_file_missing(self, ftp_client_factory):
  197. """download_file() returns None for missing file."""
  198. client = ftp_client_factory()
  199. client.connect()
  200. result = client.download_file("/cache/does_not_exist.txt")
  201. assert result is None
  202. client.disconnect()
  203. def test_download_to_file_writes_to_disk(self, ftp_client_factory, ftp_server, tmp_path):
  204. """download_to_file() writes content to local filesystem."""
  205. content = b"Downloaded content"
  206. ftp_server.add_file("cache/dl.bin", content)
  207. local = tmp_path / "output" / "dl.bin"
  208. client = ftp_client_factory()
  209. client.connect()
  210. result = client.download_to_file("/cache/dl.bin", local)
  211. assert result is True
  212. assert local.read_bytes() == content
  213. client.disconnect()
  214. def test_download_to_file_creates_parent_dirs(self, ftp_client_factory, ftp_server, tmp_path):
  215. """download_to_file() creates parent directories automatically."""
  216. ftp_server.add_file("cache/nested.txt", b"nested content")
  217. local = tmp_path / "deep" / "nested" / "path" / "nested.txt"
  218. client = ftp_client_factory()
  219. client.connect()
  220. result = client.download_to_file("/cache/nested.txt", local)
  221. assert result is True
  222. assert local.exists()
  223. client.disconnect()
  224. def test_zero_byte_download_returns_false(self, ftp_client_factory, ftp_server, tmp_path):
  225. """0-byte download returns False and cleans up (regression test)."""
  226. ftp_server.add_file("cache/empty.bin", b"")
  227. local = tmp_path / "empty.bin"
  228. client = ftp_client_factory()
  229. client.connect()
  230. result = client.download_to_file("/cache/empty.bin", local)
  231. assert result is False
  232. assert not local.exists()
  233. client.disconnect()
  234. def test_download_to_file_missing_raises_not_on_printer(self, ftp_client_factory, tmp_path):
  235. """Missing file raises FileNotOnPrinterError so callers can short-circuit
  236. the retry loop — 550 means the file isn't there and retrying won't help."""
  237. from backend.app.services.bambu_ftp import FileNotOnPrinterError
  238. local = tmp_path / "missing.bin"
  239. client = ftp_client_factory()
  240. client.connect()
  241. try:
  242. with pytest.raises(FileNotOnPrinterError):
  243. client.download_to_file("/cache/no_such_file.bin", local)
  244. finally:
  245. client.disconnect()
  246. def test_download_large_file(self, ftp_client_factory, ftp_server):
  247. """Large file download (>1MB) works correctly."""
  248. large_content = b"X" * (1024 * 1024 + 500) # ~1MB + 500 bytes
  249. ftp_server.add_file("cache/large.bin", large_content)
  250. client = ftp_client_factory()
  251. client.connect()
  252. result = client.download_file("/cache/large.bin")
  253. assert result == large_content
  254. client.disconnect()
  255. def test_download_not_connected(self):
  256. """download_file() returns None when not connected."""
  257. client = BambuFTPClient("127.0.0.1", "12345678")
  258. assert client.download_file("/cache/test.bin") is None
  259. # ---------------------------------------------------------------------------
  260. # TestUpload
  261. # ---------------------------------------------------------------------------
  262. class TestUpload:
  263. """Tests for file upload operations."""
  264. def test_upload_success(self, ftp_client_factory, ftp_server, tmp_path):
  265. """Successful upload via transfercmd (not storbinary)."""
  266. content = b"Upload test content"
  267. local = tmp_path / "upload.3mf"
  268. local.write_bytes(content)
  269. client = ftp_client_factory()
  270. client.connect()
  271. result = client.upload_file(local, "/cache/upload.3mf")
  272. assert result is True
  273. client.disconnect()
  274. # Verify via fresh connection (upload_file skips voidresp() for all
  275. # models, so the original session can't be reused for download)
  276. time.sleep(_UPLOAD_FLUSH_DELAY)
  277. client2 = ftp_client_factory()
  278. client2.connect()
  279. downloaded = client2.download_file("/cache/upload.3mf")
  280. assert downloaded == content
  281. client2.disconnect()
  282. def test_upload_progress_callback(self, ftp_client_factory, ftp_server, tmp_path):
  283. """Progress callback receives updates during upload."""
  284. content = b"P" * 2048
  285. local = tmp_path / "progress.bin"
  286. local.write_bytes(content)
  287. progress_calls = []
  288. def on_progress(uploaded, total):
  289. progress_calls.append((uploaded, total))
  290. client = ftp_client_factory()
  291. client.connect()
  292. client.upload_file(local, "/cache/progress.bin", on_progress)
  293. assert len(progress_calls) >= 1
  294. # Last call should report full file uploaded
  295. assert progress_calls[-1][0] == len(content)
  296. assert progress_calls[-1][1] == len(content)
  297. client.disconnect()
  298. def test_upload_not_connected(self, tmp_path):
  299. """Upload when not connected returns False."""
  300. local = tmp_path / "test.bin"
  301. local.write_bytes(b"data")
  302. client = BambuFTPClient("127.0.0.1", "12345678")
  303. assert client.upload_file(local, "/cache/test.bin") is False
  304. def test_upload_553_no_sd_card(self, ftp_client_factory, ftp_server, tmp_path):
  305. """553 error (no SD card) returns False."""
  306. ftp_server.inject_failure("STOR", 553, "Could not create file.")
  307. local = tmp_path / "test.bin"
  308. local.write_bytes(b"data")
  309. client = ftp_client_factory()
  310. client.connect()
  311. result = client.upload_file(local, "/cache/test.bin")
  312. assert result is False
  313. client.disconnect()
  314. def test_upload_550_permission_denied(self, ftp_client_factory, ftp_server, tmp_path):
  315. """550 error (permission denied) returns False."""
  316. ftp_server.inject_failure("STOR", 550, "Permission denied.")
  317. local = tmp_path / "test.bin"
  318. local.write_bytes(b"data")
  319. client = ftp_client_factory()
  320. client.connect()
  321. result = client.upload_file(local, "/cache/test.bin")
  322. assert result is False
  323. client.disconnect()
  324. def test_upload_552_storage_full(self, ftp_client_factory, ftp_server, tmp_path):
  325. """552 error (storage full) returns False."""
  326. ftp_server.inject_failure("STOR", 552, "Storage quota exceeded.")
  327. local = tmp_path / "test.bin"
  328. local.write_bytes(b"data")
  329. client = ftp_client_factory()
  330. client.connect()
  331. result = client.upload_file(local, "/cache/test.bin")
  332. assert result is False
  333. client.disconnect()
  334. def test_upload_bytes_success(self, ftp_client_factory, ftp_server):
  335. """upload_bytes() writes data to server."""
  336. data = b"Bytes upload content"
  337. client = ftp_client_factory()
  338. client.connect()
  339. result = client.upload_bytes(data, "/cache/bytes.bin")
  340. assert result is True
  341. client.disconnect()
  342. # Verify via fresh connection
  343. time.sleep(_UPLOAD_FLUSH_DELAY)
  344. client2 = ftp_client_factory()
  345. client2.connect()
  346. downloaded = client2.download_file("/cache/bytes.bin")
  347. assert downloaded == data
  348. client2.disconnect()
  349. def test_upload_bytes_failure(self, ftp_client_factory, ftp_server):
  350. """upload_bytes() returns False on STOR failure."""
  351. ftp_server.inject_failure("STOR", 553, "No space.")
  352. client = ftp_client_factory()
  353. client.connect()
  354. result = client.upload_bytes(b"data", "/cache/fail.bin")
  355. assert result is False
  356. client.disconnect()
  357. def test_upload_large_chunked(self, ftp_client_factory, ftp_server, tmp_path):
  358. """Large file upload in chunks completes without error.
  359. Uses 2.5MB to trigger multiple chunks with 64KB CHUNK_SIZE.
  360. Content verification skipped because upload_file() skips
  361. voidresp() for all models, so the server may still be flushing
  362. when we check. The upload result=True confirms the client sent
  363. all chunks without error.
  364. """
  365. content = b"C" * (1024 * 1024 * 2 + 512 * 1024)
  366. local = tmp_path / "large.bin"
  367. local.write_bytes(content)
  368. progress_calls = []
  369. def on_progress(uploaded, total):
  370. progress_calls.append((uploaded, total))
  371. client = ftp_client_factory()
  372. client.connect()
  373. result = client.upload_file(local, "/cache/large.bin", on_progress)
  374. assert result is True
  375. # Verify many chunks were sent (2.5MB / 64KB = 40 chunks)
  376. assert len(progress_calls) >= 38
  377. assert progress_calls[-1][0] == len(content)
  378. client.disconnect()
  379. # ---------------------------------------------------------------------------
  380. # TestDelete
  381. # ---------------------------------------------------------------------------
  382. class TestDelete:
  383. """Tests for file deletion."""
  384. def test_delete_success(self, ftp_client_factory, ftp_server):
  385. """Successful file deletion."""
  386. ftp_server.add_file("cache/to_delete.bin", b"delete me")
  387. client = ftp_client_factory()
  388. client.connect()
  389. result = client.delete_file("/cache/to_delete.bin")
  390. assert result is True
  391. assert not ftp_server.file_exists("cache/to_delete.bin")
  392. client.disconnect()
  393. def test_delete_not_found(self, ftp_client_factory):
  394. """Deleting a nonexistent file returns False."""
  395. client = ftp_client_factory()
  396. client.connect()
  397. result = client.delete_file("/cache/no_such_file.bin")
  398. assert result is False
  399. client.disconnect()
  400. def test_delete_not_connected(self):
  401. """Delete when not connected returns False."""
  402. client = BambuFTPClient("127.0.0.1", "12345678")
  403. assert client.delete_file("/cache/test.bin") is False
  404. # ---------------------------------------------------------------------------
  405. # TestFileSize
  406. # ---------------------------------------------------------------------------
  407. class TestFileSize:
  408. """Tests for get_file_size."""
  409. def test_file_size_correct(self, ftp_client_factory, ftp_server):
  410. """Returns correct file size."""
  411. ftp_server.add_file("cache/sized.bin", b"a" * 4096)
  412. client = ftp_client_factory()
  413. client.connect()
  414. size = client.get_file_size("/cache/sized.bin")
  415. assert size == 4096
  416. client.disconnect()
  417. def test_file_size_missing(self, ftp_client_factory):
  418. """Returns None for missing file."""
  419. client = ftp_client_factory()
  420. client.connect()
  421. size = client.get_file_size("/cache/no_file.bin")
  422. assert size is None
  423. client.disconnect()
  424. def test_file_size_not_connected(self):
  425. """Returns None when not connected."""
  426. client = BambuFTPClient("127.0.0.1", "12345678")
  427. assert client.get_file_size("/cache/test.bin") is None
  428. # ---------------------------------------------------------------------------
  429. # TestStorageInfo
  430. # ---------------------------------------------------------------------------
  431. class TestStorageInfo:
  432. """Tests for storage info and diagnostics."""
  433. def test_avbl_parsed(self, ftp_client_factory, ftp_server):
  434. """AVBL response is parsed for free_bytes."""
  435. ftp_server.set_avbl_bytes(5000000000)
  436. client = ftp_client_factory()
  437. client.connect()
  438. info = client.get_storage_info()
  439. assert info is not None
  440. assert info["free_bytes"] == 5000000000
  441. client.disconnect()
  442. def test_used_bytes_from_scan(self, ftp_client_factory, ftp_server):
  443. """used_bytes calculated from directory scan."""
  444. ftp_server.add_file("cache/file1.bin", b"a" * 1000)
  445. ftp_server.add_file("cache/file2.bin", b"b" * 2000)
  446. client = ftp_client_factory()
  447. client.connect()
  448. info = client.get_storage_info()
  449. assert info is not None
  450. assert info["used_bytes"] >= 3000 # At least these two files
  451. client.disconnect()
  452. def test_storage_info_not_connected(self):
  453. """Returns None when not connected."""
  454. client = BambuFTPClient("127.0.0.1", "12345678")
  455. assert client.get_storage_info() is None
  456. def test_diagnose_storage_success(self, ftp_client_factory, ftp_server):
  457. """diagnose_storage() returns connected=True with working diagnostics."""
  458. client = ftp_client_factory()
  459. client.connect()
  460. diag = client.diagnose_storage()
  461. assert diag["connected"] is True
  462. assert diag["can_list_root"] is True
  463. assert diag["can_list_cache"] is True
  464. assert diag["pwd"] is not None
  465. assert diag["storage_info"] is not None
  466. client.disconnect()
  467. def test_diagnose_storage_not_connected(self):
  468. """diagnose_storage() reports not connected."""
  469. client = BambuFTPClient("127.0.0.1", "12345678")
  470. diag = client.diagnose_storage()
  471. assert diag["connected"] is False
  472. assert "FTP not connected" in diag["errors"]
  473. # ---------------------------------------------------------------------------
  474. # TestModelSpecificBehavior
  475. # ---------------------------------------------------------------------------
  476. class TestModelSpecificBehavior:
  477. """Tests for printer model-specific FTP behavior."""
  478. def test_x1c_upload(self, ftp_client_factory, ftp_server, tmp_path):
  479. """X1C upload with session reuse succeeds."""
  480. content = b"X1C upload data"
  481. local = tmp_path / "x1c.3mf"
  482. local.write_bytes(content)
  483. client = ftp_client_factory(printer_model="X1C")
  484. client.connect()
  485. result = client.upload_file(local, "/cache/x1c.3mf")
  486. assert result is True
  487. client.disconnect()
  488. # Verify via fresh connection
  489. time.sleep(_UPLOAD_FLUSH_DELAY)
  490. client2 = ftp_client_factory(printer_model="X1C")
  491. client2.connect()
  492. downloaded = client2.download_file("/cache/x1c.3mf")
  493. assert downloaded == content
  494. client2.disconnect()
  495. def test_a1_upload_prot_c(self, ftp_client_factory, ftp_server, tmp_path):
  496. """A1 model upload with prot_c succeeds."""
  497. content = b"A1 upload data"
  498. local = tmp_path / "a1.3mf"
  499. local.write_bytes(content)
  500. client = ftp_client_factory(printer_model="A1", force_prot_c=True)
  501. client.connect()
  502. result = client.upload_file(local, "/cache/a1.3mf")
  503. assert result is True
  504. client.disconnect()
  505. # Verify via fresh connection
  506. time.sleep(_UPLOAD_FLUSH_DELAY)
  507. client2 = ftp_client_factory(printer_model="A1", force_prot_c=True)
  508. client2.connect()
  509. downloaded = client2.download_file("/cache/a1.3mf")
  510. assert downloaded == content
  511. client2.disconnect()
  512. def test_a1_mini_upload(self, ftp_client_factory, ftp_server, tmp_path):
  513. """A1 Mini model upload succeeds."""
  514. content = b"A1 Mini data"
  515. local = tmp_path / "a1mini.3mf"
  516. local.write_bytes(content)
  517. client = ftp_client_factory(printer_model="A1 Mini", force_prot_c=True)
  518. client.connect()
  519. result = client.upload_file(local, "/cache/a1mini.3mf")
  520. assert result is True
  521. client.disconnect()
  522. def test_p1s_upload(self, ftp_client_factory, ftp_server, tmp_path):
  523. """P1S model upload with session reuse succeeds."""
  524. content = b"P1S upload data"
  525. local = tmp_path / "p1s.3mf"
  526. local.write_bytes(content)
  527. client = ftp_client_factory(printer_model="P1S")
  528. client.connect()
  529. result = client.upload_file(local, "/cache/p1s.3mf")
  530. assert result is True
  531. client.disconnect()
  532. def test_unknown_model_defaults_prot_p(self, ftp_client_factory):
  533. """Unknown model defaults to prot_p."""
  534. client = ftp_client_factory(printer_model="FuturePrinter3000")
  535. assert client._is_a1_model() is False
  536. assert client._should_use_prot_c() is False
  537. assert client.connect() is True
  538. client.disconnect()
  539. def test_mode_cache_persists_and_clears(self, ftp_client_factory):
  540. """Mode cache works within a test and clears between tests."""
  541. # Cache should be empty at start (autouse fixture clears it)
  542. assert BambuFTPClient._mode_cache == {}
  543. # Connect and cache a mode
  544. BambuFTPClient.cache_mode("127.0.0.1", "prot_p")
  545. assert BambuFTPClient._mode_cache["127.0.0.1"] == "prot_p"
  546. # New client for same IP uses cached mode
  547. client = ftp_client_factory(printer_model="A1")
  548. assert client._get_cached_mode() == "prot_p"
  549. assert client._should_use_prot_c() is False
  550. client.disconnect()
  551. # ---------------------------------------------------------------------------
  552. # TestAsyncWrappers
  553. # ---------------------------------------------------------------------------
  554. class TestAsyncWrappers:
  555. """Tests for async wrapper functions using patch_ftp_port fixture."""
  556. @pytest.mark.asyncio
  557. async def test_upload_file_async_success(self, patch_ftp_port, tmp_path):
  558. """upload_file_async succeeds for X1C."""
  559. content = b"async upload"
  560. local = tmp_path / "async_up.3mf"
  561. local.write_bytes(content)
  562. result = await upload_file_async(
  563. "127.0.0.1",
  564. "12345678",
  565. local,
  566. "/cache/async_up.3mf",
  567. timeout=30.0,
  568. printer_model="X1C",
  569. )
  570. assert result is True
  571. @pytest.mark.asyncio
  572. async def test_upload_file_async_a1_fallback(self, patch_ftp_port, tmp_path):
  573. """upload_file_async tries prot_p then falls back to prot_c for A1."""
  574. content = b"a1 async upload"
  575. local = tmp_path / "a1_async.3mf"
  576. local.write_bytes(content)
  577. # For A1 models, if prot_p succeeds we get True.
  578. # If prot_p fails, it tries prot_c. Either way should succeed
  579. # against our mock server which accepts both.
  580. result = await upload_file_async(
  581. "127.0.0.1",
  582. "12345678",
  583. local,
  584. "/cache/a1_async.3mf",
  585. timeout=30.0,
  586. printer_model="A1",
  587. )
  588. assert result is True
  589. @pytest.mark.asyncio
  590. async def test_download_file_async_success(self, patch_ftp_port, tmp_path):
  591. """download_file_async succeeds."""
  592. server = patch_ftp_port
  593. content = b"async download content"
  594. server.add_file("cache/async_dl.bin", content)
  595. local = tmp_path / "async_dl.bin"
  596. result = await download_file_async(
  597. "127.0.0.1",
  598. "12345678",
  599. "/cache/async_dl.bin",
  600. local,
  601. timeout=30.0,
  602. printer_model="X1C",
  603. )
  604. assert result is True
  605. assert local.read_bytes() == content
  606. @pytest.mark.asyncio
  607. async def test_download_file_async_a1_fallback(self, patch_ftp_port, tmp_path):
  608. """download_file_async falls back for A1 models."""
  609. server = patch_ftp_port
  610. server.add_file("cache/a1_dl.bin", b"a1 data")
  611. local = tmp_path / "a1_dl.bin"
  612. result = await download_file_async(
  613. "127.0.0.1",
  614. "12345678",
  615. "/cache/a1_dl.bin",
  616. local,
  617. timeout=30.0,
  618. printer_model="A1",
  619. )
  620. assert result is True
  621. @pytest.mark.asyncio
  622. async def test_download_file_async_timeout_salvages_completed_zombie(self, tmp_path, monkeypatch):
  623. """Executor thread that completes after wait_for timeout is salvaged.
  624. asyncio.wait_for cannot cancel run_in_executor threads, so the FTP
  625. download may still complete after we give up waiting. If the thread
  626. genuinely finished (signalled via completion["success"] and the file
  627. is on disk), download_file_async should return True rather than False.
  628. Regression for #972: A1 user with 14 MB 3MF hit the hardcoded 60s
  629. timeout, but the download thread finished ~45s later. The successful
  630. file was written to disk but the async wrapper returned False, so the
  631. archive was created as a fallback with no 3MF data.
  632. """
  633. from backend.app.services import bambu_ftp
  634. # Clear mode cache so prot_p path is exercised.
  635. bambu_ftp.BambuFTPClient._mode_cache.pop("127.0.0.1", None)
  636. local = tmp_path / "zombie.bin"
  637. expected_content = b"late arrival but complete"
  638. class FakeClient:
  639. """Connects instantly, download_to_file sleeps past wait_for's
  640. timeout then writes the file and returns True."""
  641. def __init__(self, *args, **kwargs):
  642. pass
  643. def connect(self):
  644. return True
  645. def download_to_file(self, remote_path, local_path):
  646. time.sleep(0.4) # longer than wait_for timeout=0.1
  647. local_path.write_bytes(expected_content)
  648. return True
  649. def disconnect(self):
  650. pass
  651. monkeypatch.setattr(bambu_ftp, "BambuFTPClient", FakeClient)
  652. monkeypatch.setattr(FakeClient, "_mode_cache", {}, raising=False)
  653. monkeypatch.setattr(FakeClient, "A1_MODELS", {"A1"}, raising=False)
  654. def _noop_cache(ip, mode):
  655. pass
  656. monkeypatch.setattr(FakeClient, "cache_mode", staticmethod(_noop_cache), raising=False)
  657. result = await download_file_async(
  658. "127.0.0.1",
  659. "12345678",
  660. "/cache/zombie.bin",
  661. local,
  662. timeout=0.1,
  663. printer_model="X1C",
  664. )
  665. assert result is True
  666. assert local.read_bytes() == expected_content
  667. @pytest.mark.asyncio
  668. async def test_download_file_async_timeout_no_salvage_when_incomplete(self, tmp_path, monkeypatch):
  669. """Timeout returns False when thread has not signalled success.
  670. A partial file on disk (mid-retrbinary) must NOT be mistaken for a
  671. completed download — only the thread's explicit success flag permits
  672. salvage.
  673. """
  674. from backend.app.services import bambu_ftp
  675. bambu_ftp.BambuFTPClient._mode_cache.pop("127.0.0.1", None)
  676. local = tmp_path / "partial.bin"
  677. class FakeClient:
  678. def __init__(self, *args, **kwargs):
  679. pass
  680. def connect(self):
  681. return True
  682. def download_to_file(self, remote_path, local_path):
  683. # Simulate an in-progress partial write that never completes
  684. # within the salvage grace period.
  685. local_path.write_bytes(b"partial...")
  686. time.sleep(2.0)
  687. return True # would complete eventually, but too late
  688. def disconnect(self):
  689. pass
  690. monkeypatch.setattr(bambu_ftp, "BambuFTPClient", FakeClient)
  691. monkeypatch.setattr(FakeClient, "_mode_cache", {}, raising=False)
  692. monkeypatch.setattr(FakeClient, "A1_MODELS", set(), raising=False)
  693. monkeypatch.setattr(FakeClient, "cache_mode", staticmethod(lambda ip, mode: None), raising=False)
  694. result = await download_file_async(
  695. "127.0.0.1",
  696. "12345678",
  697. "/cache/partial.bin",
  698. local,
  699. timeout=0.1,
  700. printer_model="X1C",
  701. )
  702. assert result is False
  703. @pytest.mark.asyncio
  704. async def test_download_file_try_paths_first_succeeds(self, patch_ftp_port, tmp_path):
  705. """download_file_try_paths_async succeeds on first path."""
  706. server = patch_ftp_port
  707. server.add_file("cache/try1.bin", b"first path")
  708. local = tmp_path / "try.bin"
  709. result = await download_file_try_paths_async(
  710. "127.0.0.1",
  711. "12345678",
  712. ["/cache/try1.bin", "/cache/try2.bin"],
  713. local,
  714. printer_model="X1C",
  715. )
  716. assert result is True
  717. assert local.read_bytes() == b"first path"
  718. @pytest.mark.asyncio
  719. async def test_download_file_try_paths_fallback(self, patch_ftp_port, tmp_path):
  720. """download_file_try_paths_async falls back to second path."""
  721. server = patch_ftp_port
  722. server.add_file("cache/second.bin", b"second path")
  723. local = tmp_path / "fallback.bin"
  724. result = await download_file_try_paths_async(
  725. "127.0.0.1",
  726. "12345678",
  727. ["/cache/missing.bin", "/cache/second.bin"],
  728. local,
  729. printer_model="X1C",
  730. )
  731. assert result is True
  732. assert local.read_bytes() == b"second path"
  733. @pytest.mark.asyncio
  734. async def test_list_files_async_success(self, patch_ftp_port):
  735. """list_files_async returns file list."""
  736. server = patch_ftp_port
  737. server.add_file("cache/listed.bin", b"data")
  738. result = await list_files_async(
  739. "127.0.0.1",
  740. "12345678",
  741. "/cache",
  742. timeout=30.0,
  743. printer_model="X1C",
  744. )
  745. names = {f["name"] for f in result}
  746. assert "listed.bin" in names
  747. @pytest.mark.asyncio
  748. async def test_delete_file_async_success(self, patch_ftp_port):
  749. """delete_file_async deletes a file."""
  750. server = patch_ftp_port
  751. server.add_file("cache/to_async_del.bin", b"delete me")
  752. result = await delete_file_async(
  753. "127.0.0.1",
  754. "12345678",
  755. "/cache/to_async_del.bin",
  756. printer_model="X1C",
  757. )
  758. assert result is True
  759. assert not server.file_exists("cache/to_async_del.bin")
  760. # ---------------------------------------------------------------------------
  761. # TestFailureScenarios
  762. # ---------------------------------------------------------------------------
  763. class TestFailureScenarios:
  764. """Regression tests for known FTP failure modes."""
  765. def test_550_caught_by_broad_except(self, ftp_client_factory, ftp_server, tmp_path):
  766. """550 error_perm is caught by (OSError, ftplib.Error) handler.
  767. Regression: error_perm is a subclass of ftplib.Error, so the
  768. broad except clause in upload_file catches it correctly.
  769. """
  770. ftp_server.inject_failure("STOR", 550, "Permission denied.")
  771. local = tmp_path / "test.bin"
  772. local.write_bytes(b"data")
  773. client = ftp_client_factory()
  774. client.connect()
  775. result = client.upload_file(local, "/cache/test.bin")
  776. assert result is False
  777. client.disconnect()
  778. def test_zero_byte_download_detected(self, ftp_client_factory, ftp_server, tmp_path):
  779. """0-byte download is detected and file is cleaned up.
  780. Regression: Prior to fix, 0-byte downloads were reported as success.
  781. """
  782. ftp_server.add_file("cache/zero.bin", b"")
  783. local = tmp_path / "zero.bin"
  784. client = ftp_client_factory()
  785. client.connect()
  786. result = client.download_to_file("/cache/zero.bin", local)
  787. assert result is False
  788. assert not local.exists()
  789. client.disconnect()
  790. def test_connection_refused_handled(self):
  791. """Connection refused is handled gracefully."""
  792. client = BambuFTPClient("127.0.0.1", "12345678", timeout=2.0)
  793. client.FTP_PORT = 1 # Almost certainly not listening
  794. assert client.connect() is False
  795. def test_auth_failure_530(self, ftp_client_factory, ftp_server):
  796. """530 authentication failure returns False."""
  797. ftp_server.inject_failure("PASS", 530, "Login incorrect.")
  798. client = ftp_client_factory()
  799. result = client.connect()
  800. assert result is False
  801. def test_retr_550_handled(self, ftp_client_factory, ftp_server):
  802. """RETR 550 (file not found) returns None."""
  803. ftp_server.inject_failure("RETR", 550, "File not found.")
  804. ftp_server.add_file("cache/exists.bin", b"data")
  805. client = ftp_client_factory()
  806. client.connect()
  807. result = client.download_file("/cache/exists.bin")
  808. assert result is None
  809. client.disconnect()
  810. def test_cwd_550_handled(self, ftp_client_factory, ftp_server):
  811. """CWD 550 is handled in list_files."""
  812. ftp_server.inject_failure("CWD", 550, "Directory not found.")
  813. client = ftp_client_factory()
  814. client.connect()
  815. result = client.list_files("/nonexistent")
  816. assert result == []
  817. client.disconnect()
  818. def test_stor_553_handled(self, ftp_client_factory, ftp_server, tmp_path):
  819. """STOR 553 (no SD card) handled gracefully."""
  820. ftp_server.inject_failure("STOR", 553, "Could not create file.")
  821. local = tmp_path / "test.bin"
  822. local.write_bytes(b"test")
  823. client = ftp_client_factory()
  824. client.connect()
  825. result = client.upload_file(local, "/cache/test.bin")
  826. assert result is False
  827. client.disconnect()
  828. def test_diagnose_storage_cwd_failure_doesnt_propagate(self, ftp_client_factory, ftp_server):
  829. """diagnose_storage CWD failure doesn't crash the whole operation.
  830. Regression: diagnose_storage() was called in the upload path and
  831. a CWD failure would propagate and crash the upload.
  832. """
  833. ftp_server.inject_failure("CWD", 550, "No such directory.", count=2)
  834. client = ftp_client_factory()
  835. client.connect()
  836. diag = client.diagnose_storage()
  837. # Should still return results (with errors noted)
  838. assert diag["connected"] is True
  839. assert len(diag["errors"]) > 0
  840. client.disconnect()
  841. def test_failure_injection_count_decrements(self, ftp_client_factory, ftp_server):
  842. """Failure injection with count decrements and eventually succeeds."""
  843. ftp_server.add_file("cache/retry.bin", b"data after retry")
  844. ftp_server.inject_failure("RETR", 550, "Temporary error.", count=1)
  845. client = ftp_client_factory()
  846. client.connect()
  847. # First attempt fails
  848. result1 = client.download_file("/cache/retry.bin")
  849. assert result1 is None
  850. # Second attempt succeeds (failure count exhausted)
  851. result2 = client.download_file("/cache/retry.bin")
  852. assert result2 == b"data after retry"
  853. client.disconnect()
  854. def test_upload_skips_voidresp(self, ftp_client_factory, ftp_server, tmp_path):
  855. """Upload returns True without calling voidresp() for any model.
  856. voidresp() is skipped for all models: A1 printers hang on it,
  857. H2D printers delay the 226 response by 30+ seconds, and X1C/P1S
  858. gain nothing from waiting. The file is on the SD card once
  859. sendall() returns.
  860. """
  861. content = b"voidresp test data"
  862. local = tmp_path / "voidresp_test.3mf"
  863. local.write_bytes(content)
  864. for model in ("X1C", "A1", "H2D", None):
  865. client = ftp_client_factory(printer_model=model)
  866. client.connect()
  867. result = client.upload_file(local, "/cache/voidresp_test.3mf")
  868. assert result is True, f"Upload failed for model={model}"
  869. client.disconnect()
  870. # Verify the file is actually on the server
  871. time.sleep(_UPLOAD_FLUSH_DELAY)
  872. client2 = ftp_client_factory()
  873. client2.connect()
  874. downloaded = client2.download_file("/cache/voidresp_test.3mf")
  875. assert downloaded == content, f"Content mismatch for model={model}"
  876. client2.disconnect()
  877. # ---------------------------------------------------------------------------
  878. # Short-circuit retries on 550 (#972)
  879. # ---------------------------------------------------------------------------
  880. class TestFileNotOnPrinterShortCircuit:
  881. """FileNotOnPrinterError must bypass the retry budget.
  882. Before this fix, a 3MF path that wasn't on the printer (550) cost
  883. `ftp_retry_count + 1` attempts × `ftp_retry_delay` seconds per candidate
  884. path. With ftp_retry_count=10 and four candidate paths, that's ~22 min
  885. of dead retries before the real path is tried. #972 in the wild showed
  886. 48 min of retrying paths that didn't exist.
  887. """
  888. async def test_with_ftp_retry_propagates_file_not_on_printer_without_retrying(self):
  889. """with_ftp_retry raises FileNotOnPrinterError on first attempt.
  890. Verifies non_retry_exceptions short-circuits before the retry loop
  891. has a chance to sleep and try again.
  892. """
  893. attempts = {"n": 0}
  894. async def always_missing(*_args, **_kwargs):
  895. attempts["n"] += 1
  896. raise FileNotOnPrinterError("/cache/absent.3mf: 550")
  897. with pytest.raises(FileNotOnPrinterError):
  898. await with_ftp_retry(
  899. always_missing,
  900. max_retries=10,
  901. retry_delay=0.01,
  902. operation_name="test 550 short-circuit",
  903. non_retry_exceptions=(FileNotOnPrinterError,),
  904. )
  905. assert attempts["n"] == 1, "550 must not trigger any retry"
  906. async def test_with_ftp_retry_still_retries_transient_errors(self):
  907. """Non-550 exceptions continue to retry up to max_retries + 1."""
  908. attempts = {"n": 0}
  909. async def flaky(*_args, **_kwargs):
  910. attempts["n"] += 1
  911. raise TimeoutError("transient")
  912. result = await with_ftp_retry(
  913. flaky,
  914. max_retries=2,
  915. retry_delay=0.01,
  916. operation_name="test transient retries",
  917. non_retry_exceptions=(FileNotOnPrinterError,),
  918. )
  919. assert result is None
  920. assert attempts["n"] == 3, "Transient errors should retry to exhaustion"
  921. def test_download_to_file_raises_on_missing_path(self, ftp_client_factory, tmp_path):
  922. """download_to_file surfaces 550 as FileNotOnPrinterError end-to-end
  923. against the real mock FTPS server, not just a hand-rolled mock."""
  924. local = tmp_path / "never_downloaded.3mf"
  925. client = ftp_client_factory()
  926. client.connect()
  927. try:
  928. with pytest.raises(FileNotOnPrinterError):
  929. client.download_to_file("/cache/does_not_exist.3mf", local)
  930. finally:
  931. client.disconnect()
  932. assert not local.exists(), "Partial file must be cleaned up on 550"
  933. # ---------------------------------------------------------------------------
  934. # 3MF download cache (#972)
  935. # ---------------------------------------------------------------------------
  936. class TestThreeMFCache:
  937. """Cover endpoint and archive flow share downloaded 3MF bytes via this
  938. cache. Tests isolate themselves with clear_3mf_cache(delete_files=False)
  939. so they don't clobber each other."""
  940. def setup_method(self):
  941. clear_3mf_cache(delete_files=False)
  942. def teardown_method(self):
  943. clear_3mf_cache(delete_files=False)
  944. def test_normalize_collapses_filename_variants(self):
  945. """Bambu names vary (.3mf, .gcode.3mf, with spaces) — they all map
  946. to the same cache slot so both flows agree on the key."""
  947. canonical = normalize_3mf_name("Broly_Legendary.gcode.3mf")
  948. assert normalize_3mf_name("Broly_Legendary.3mf") == canonical
  949. assert normalize_3mf_name("Broly_Legendary") == canonical
  950. # Bambu Studio rewrites spaces to underscores on upload — treat as equal
  951. assert normalize_3mf_name("Broly Legendary") == canonical
  952. # Case is also collapsed so keys match across capitalizations
  953. assert normalize_3mf_name("BROLY_LEGENDARY.3MF") == canonical
  954. def test_cache_hit_returns_stored_path(self, tmp_path):
  955. """get_cached_3mf returns the same Path that was put in."""
  956. f = tmp_path / "Broly.gcode.3mf"
  957. f.write_bytes(b"fake 3mf content")
  958. cache_3mf_download(1, "Broly.gcode.3mf", f)
  959. assert get_cached_3mf(1, "Broly.gcode.3mf") == f
  960. def test_cache_lookup_uses_normalized_name(self, tmp_path):
  961. """Caching under .gcode.3mf and querying with bare name still hits."""
  962. f = tmp_path / "Broly.gcode.3mf"
  963. f.write_bytes(b"x")
  964. cache_3mf_download(1, "Broly.gcode.3mf", f)
  965. assert get_cached_3mf(1, "Broly.3mf") == f
  966. assert get_cached_3mf(1, "Broly") == f
  967. def test_cache_miss_on_different_printer(self, tmp_path):
  968. """Printer id is part of the key — two printers never collide."""
  969. f = tmp_path / "A.3mf"
  970. f.write_bytes(b"x")
  971. cache_3mf_download(1, "A.3mf", f)
  972. assert get_cached_3mf(2, "A.3mf") is None
  973. def test_cache_evicts_when_file_deleted(self, tmp_path):
  974. """Stale entry (file gone) returns None and is dropped from the dict."""
  975. f = tmp_path / "A.3mf"
  976. f.write_bytes(b"x")
  977. cache_3mf_download(1, "A.3mf", f)
  978. f.unlink()
  979. assert get_cached_3mf(1, "A.3mf") is None
  980. # Re-populating after eviction works — no ghost entries remain.
  981. f.write_bytes(b"y")
  982. cache_3mf_download(1, "A.3mf", f)
  983. assert get_cached_3mf(1, "A.3mf") == f
  984. def test_clear_by_printer_scoped(self, tmp_path):
  985. """Clearing one printer leaves the other untouched."""
  986. f1 = tmp_path / "one.3mf"
  987. f1.write_bytes(b"1")
  988. f2 = tmp_path / "two.3mf"
  989. f2.write_bytes(b"2")
  990. cache_3mf_download(1, "one.3mf", f1)
  991. cache_3mf_download(2, "two.3mf", f2)
  992. clear_3mf_cache(1)
  993. assert get_cached_3mf(1, "one.3mf") is None
  994. assert get_cached_3mf(2, "two.3mf") == f2
  995. # clear_3mf_cache defaulted to delete_files=True, so the file is gone
  996. assert not f1.exists()
  997. assert f2.exists()
  998. def test_clear_without_deleting_files(self, tmp_path):
  999. """delete_files=False leaves files on disk — used by tests."""
  1000. f = tmp_path / "keep.3mf"
  1001. f.write_bytes(b"x")
  1002. cache_3mf_download(1, "keep.3mf", f)
  1003. clear_3mf_cache(1, delete_files=False)
  1004. assert get_cached_3mf(1, "keep.3mf") is None
  1005. assert f.exists()