test_bambu_ftp.py 39 KB

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