test_bambu_ftp.py 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872
  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 A1 compatibility,
  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 raises EOFError.
  107. Note: The current disconnect() catches (OSError, ftplib.Error) but
  108. EOFError is neither. This documents actual behavior — a future fix
  109. could add EOFError to the except clause.
  110. """
  111. from backend.tests.unit.services.mock_ftp_server import (
  112. MockBambuFTPServer,
  113. )
  114. from .conftest import _find_free_port
  115. cert_path, key_path = ftp_certs
  116. port = _find_free_port()
  117. server = MockBambuFTPServer("127.0.0.1", port, str(tmp_path), cert_path, key_path)
  118. server.start()
  119. client = BambuFTPClient("127.0.0.1", "12345678", timeout=5.0)
  120. client.FTP_PORT = port
  121. client.connect()
  122. server.stop()
  123. with pytest.raises(EOFError):
  124. client.disconnect()
  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()
  265. # 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 1MB CHUNK_SIZE.
  350. Content verification skipped because upload_file() doesn't call
  351. voidresp() (for A1 compatibility), so the server may still be
  352. flushing when we check. The upload result=True confirms the
  353. client sent 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 multiple chunks were sent
  366. assert len(progress_calls) >= 3 # 2.5MB / 1MB = at least 3 chunks
  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_try_paths_first_succeeds(self, patch_ftp_port, tmp_path):
  613. """download_file_try_paths_async succeeds on first path."""
  614. server = patch_ftp_port
  615. server.add_file("cache/try1.bin", b"first path")
  616. local = tmp_path / "try.bin"
  617. result = await download_file_try_paths_async(
  618. "127.0.0.1",
  619. "12345678",
  620. ["/cache/try1.bin", "/cache/try2.bin"],
  621. local,
  622. printer_model="X1C",
  623. )
  624. assert result is True
  625. assert local.read_bytes() == b"first path"
  626. @pytest.mark.asyncio
  627. async def test_download_file_try_paths_fallback(self, patch_ftp_port, tmp_path):
  628. """download_file_try_paths_async falls back to second path."""
  629. server = patch_ftp_port
  630. server.add_file("cache/second.bin", b"second path")
  631. local = tmp_path / "fallback.bin"
  632. result = await download_file_try_paths_async(
  633. "127.0.0.1",
  634. "12345678",
  635. ["/cache/missing.bin", "/cache/second.bin"],
  636. local,
  637. printer_model="X1C",
  638. )
  639. assert result is True
  640. assert local.read_bytes() == b"second path"
  641. @pytest.mark.asyncio
  642. async def test_list_files_async_success(self, patch_ftp_port):
  643. """list_files_async returns file list."""
  644. server = patch_ftp_port
  645. server.add_file("cache/listed.bin", b"data")
  646. result = await list_files_async(
  647. "127.0.0.1",
  648. "12345678",
  649. "/cache",
  650. timeout=30.0,
  651. printer_model="X1C",
  652. )
  653. names = {f["name"] for f in result}
  654. assert "listed.bin" in names
  655. @pytest.mark.asyncio
  656. async def test_delete_file_async_success(self, patch_ftp_port):
  657. """delete_file_async deletes a file."""
  658. server = patch_ftp_port
  659. server.add_file("cache/to_async_del.bin", b"delete me")
  660. result = await delete_file_async(
  661. "127.0.0.1",
  662. "12345678",
  663. "/cache/to_async_del.bin",
  664. printer_model="X1C",
  665. )
  666. assert result is True
  667. assert not server.file_exists("cache/to_async_del.bin")
  668. # ---------------------------------------------------------------------------
  669. # TestFailureScenarios
  670. # ---------------------------------------------------------------------------
  671. class TestFailureScenarios:
  672. """Regression tests for known FTP failure modes."""
  673. def test_550_caught_by_broad_except(self, ftp_client_factory, ftp_server, tmp_path):
  674. """550 error_perm is caught by (OSError, ftplib.Error) handler.
  675. Regression: error_perm is a subclass of ftplib.Error, so the
  676. broad except clause in upload_file catches it correctly.
  677. """
  678. ftp_server.inject_failure("STOR", 550, "Permission denied.")
  679. local = tmp_path / "test.bin"
  680. local.write_bytes(b"data")
  681. client = ftp_client_factory()
  682. client.connect()
  683. result = client.upload_file(local, "/cache/test.bin")
  684. assert result is False
  685. client.disconnect()
  686. def test_zero_byte_download_detected(self, ftp_client_factory, ftp_server, tmp_path):
  687. """0-byte download is detected and file is cleaned up.
  688. Regression: Prior to fix, 0-byte downloads were reported as success.
  689. """
  690. ftp_server.add_file("cache/zero.bin", b"")
  691. local = tmp_path / "zero.bin"
  692. client = ftp_client_factory()
  693. client.connect()
  694. result = client.download_to_file("/cache/zero.bin", local)
  695. assert result is False
  696. assert not local.exists()
  697. client.disconnect()
  698. def test_connection_refused_handled(self):
  699. """Connection refused is handled gracefully."""
  700. client = BambuFTPClient("127.0.0.1", "12345678", timeout=2.0)
  701. client.FTP_PORT = 1 # Almost certainly not listening
  702. assert client.connect() is False
  703. def test_auth_failure_530(self, ftp_client_factory, ftp_server):
  704. """530 authentication failure returns False."""
  705. ftp_server.inject_failure("PASS", 530, "Login incorrect.")
  706. client = ftp_client_factory()
  707. result = client.connect()
  708. assert result is False
  709. def test_retr_550_handled(self, ftp_client_factory, ftp_server):
  710. """RETR 550 (file not found) returns None."""
  711. ftp_server.inject_failure("RETR", 550, "File not found.")
  712. ftp_server.add_file("cache/exists.bin", b"data")
  713. client = ftp_client_factory()
  714. client.connect()
  715. result = client.download_file("/cache/exists.bin")
  716. assert result is None
  717. client.disconnect()
  718. def test_cwd_550_handled(self, ftp_client_factory, ftp_server):
  719. """CWD 550 is handled in list_files."""
  720. ftp_server.inject_failure("CWD", 550, "Directory not found.")
  721. client = ftp_client_factory()
  722. client.connect()
  723. result = client.list_files("/nonexistent")
  724. assert result == []
  725. client.disconnect()
  726. def test_stor_553_handled(self, ftp_client_factory, ftp_server, tmp_path):
  727. """STOR 553 (no SD card) handled gracefully."""
  728. ftp_server.inject_failure("STOR", 553, "Could not create file.")
  729. local = tmp_path / "test.bin"
  730. local.write_bytes(b"test")
  731. client = ftp_client_factory()
  732. client.connect()
  733. result = client.upload_file(local, "/cache/test.bin")
  734. assert result is False
  735. client.disconnect()
  736. def test_diagnose_storage_cwd_failure_doesnt_propagate(self, ftp_client_factory, ftp_server):
  737. """diagnose_storage CWD failure doesn't crash the whole operation.
  738. Regression: diagnose_storage() was called in the upload path and
  739. a CWD failure would propagate and crash the upload.
  740. """
  741. ftp_server.inject_failure("CWD", 550, "No such directory.", count=2)
  742. client = ftp_client_factory()
  743. client.connect()
  744. diag = client.diagnose_storage()
  745. # Should still return results (with errors noted)
  746. assert diag["connected"] is True
  747. assert len(diag["errors"]) > 0
  748. client.disconnect()
  749. def test_failure_injection_count_decrements(self, ftp_client_factory, ftp_server):
  750. """Failure injection with count decrements and eventually succeeds."""
  751. ftp_server.add_file("cache/retry.bin", b"data after retry")
  752. ftp_server.inject_failure("RETR", 550, "Temporary error.", count=1)
  753. client = ftp_client_factory()
  754. client.connect()
  755. # First attempt fails
  756. result1 = client.download_file("/cache/retry.bin")
  757. assert result1 is None
  758. # Second attempt succeeds (failure count exhausted)
  759. result2 = client.download_file("/cache/retry.bin")
  760. assert result2 == b"data after retry"
  761. client.disconnect()