test_bambu_ftp.py 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864
  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_disconnect_after_server_gone(self, ftp_certs, ftp_root):
  75. """Disconnect after server has stopped raises EOFError.
  76. Note: The current disconnect() catches (OSError, ftplib.Error) but
  77. EOFError is neither. This documents actual behavior — a future fix
  78. could add EOFError to the except clause.
  79. """
  80. from backend.tests.unit.services.mock_ftp_server import (
  81. MockBambuFTPServer,
  82. )
  83. from .conftest import _find_free_port
  84. cert_path, key_path = ftp_certs
  85. port = _find_free_port()
  86. server = MockBambuFTPServer("127.0.0.1", port, str(ftp_root), cert_path, key_path)
  87. server.start()
  88. client = BambuFTPClient("127.0.0.1", "12345678", timeout=5.0)
  89. client.FTP_PORT = port
  90. client.connect()
  91. server.stop()
  92. with pytest.raises(EOFError):
  93. client.disconnect()
  94. def test_x1c_uses_prot_p(self, ftp_client_factory):
  95. """X1C model connects with prot_p (protected data channel)."""
  96. client = ftp_client_factory(printer_model="X1C")
  97. assert client.connect() is True
  98. assert client._should_use_prot_c() is False
  99. client.disconnect()
  100. def test_a1_defaults_prot_p(self, ftp_client_factory):
  101. """A1 model defaults to prot_p when no cache exists."""
  102. client = ftp_client_factory(printer_model="A1")
  103. assert client._should_use_prot_c() is False
  104. assert client.connect() is True
  105. client.disconnect()
  106. def test_a1_force_prot_c(self, ftp_client_factory):
  107. """A1 model with force_prot_c uses clear data channel."""
  108. client = ftp_client_factory(printer_model="A1", force_prot_c=True)
  109. assert client._should_use_prot_c() is True
  110. assert client.connect() is True
  111. client.disconnect()
  112. def test_cached_mode_respected(self, ftp_client_factory):
  113. """Cached mode is used on subsequent connections."""
  114. BambuFTPClient.cache_mode("127.0.0.1", "prot_c")
  115. client = ftp_client_factory(printer_model="A1")
  116. assert client._should_use_prot_c() is True
  117. assert client.connect() is True
  118. client.disconnect()
  119. # ---------------------------------------------------------------------------
  120. # TestListFiles
  121. # ---------------------------------------------------------------------------
  122. class TestListFiles:
  123. """Tests for directory listing."""
  124. def test_list_empty_directory(self, ftp_client_factory):
  125. """Listing an empty directory returns empty list."""
  126. client = ftp_client_factory()
  127. client.connect()
  128. files = client.list_files("/cache")
  129. assert files == []
  130. client.disconnect()
  131. def test_list_directory_with_files(self, ftp_client_factory, ftp_server):
  132. """Files in directory are listed correctly."""
  133. ftp_server.add_file("cache/test.3mf", b"x" * 1024)
  134. ftp_server.add_file("cache/test2.gcode", b"y" * 512)
  135. client = ftp_client_factory()
  136. client.connect()
  137. files = client.list_files("/cache")
  138. names = {f["name"] for f in files}
  139. assert "test.3mf" in names
  140. assert "test2.gcode" in names
  141. client.disconnect()
  142. def test_directories_marked(self, ftp_client_factory, ftp_server):
  143. """Subdirectories are identified with is_directory=True."""
  144. ftp_server.add_directory("model/subdir")
  145. client = ftp_client_factory()
  146. client.connect()
  147. files = client.list_files("/model")
  148. dirs = [f for f in files if f["is_directory"]]
  149. assert len(dirs) >= 1
  150. assert dirs[0]["name"] == "subdir"
  151. client.disconnect()
  152. def test_nonexistent_path_returns_empty(self, ftp_client_factory):
  153. """Listing a nonexistent path returns empty list."""
  154. client = ftp_client_factory()
  155. client.connect()
  156. files = client.list_files("/nonexistent/path")
  157. assert files == []
  158. client.disconnect()
  159. def test_file_sizes_and_paths(self, ftp_client_factory, ftp_server):
  160. """File sizes and full paths are parsed correctly."""
  161. ftp_server.add_file("cache/sized.bin", b"a" * 2048)
  162. client = ftp_client_factory()
  163. client.connect()
  164. files = client.list_files("/cache")
  165. sized = [f for f in files if f["name"] == "sized.bin"]
  166. assert len(sized) == 1
  167. assert sized[0]["size"] == 2048
  168. assert sized[0]["path"] == "/cache/sized.bin"
  169. client.disconnect()
  170. # ---------------------------------------------------------------------------
  171. # TestDownload
  172. # ---------------------------------------------------------------------------
  173. class TestDownload:
  174. """Tests for file download operations."""
  175. def test_download_file_returns_bytes(self, ftp_client_factory, ftp_server):
  176. """download_file() returns file content as bytes."""
  177. content = b"Hello FTP World!"
  178. ftp_server.add_file("cache/hello.txt", content)
  179. client = ftp_client_factory()
  180. client.connect()
  181. result = client.download_file("/cache/hello.txt")
  182. assert result == content
  183. client.disconnect()
  184. def test_download_file_missing(self, ftp_client_factory):
  185. """download_file() returns None for missing file."""
  186. client = ftp_client_factory()
  187. client.connect()
  188. result = client.download_file("/cache/does_not_exist.txt")
  189. assert result is None
  190. client.disconnect()
  191. def test_download_to_file_writes_to_disk(self, ftp_client_factory, ftp_server, tmp_path):
  192. """download_to_file() writes content to local filesystem."""
  193. content = b"Downloaded content"
  194. ftp_server.add_file("cache/dl.bin", content)
  195. local = tmp_path / "output" / "dl.bin"
  196. client = ftp_client_factory()
  197. client.connect()
  198. result = client.download_to_file("/cache/dl.bin", local)
  199. assert result is True
  200. assert local.read_bytes() == content
  201. client.disconnect()
  202. def test_download_to_file_creates_parent_dirs(self, ftp_client_factory, ftp_server, tmp_path):
  203. """download_to_file() creates parent directories automatically."""
  204. ftp_server.add_file("cache/nested.txt", b"nested content")
  205. local = tmp_path / "deep" / "nested" / "path" / "nested.txt"
  206. client = ftp_client_factory()
  207. client.connect()
  208. result = client.download_to_file("/cache/nested.txt", local)
  209. assert result is True
  210. assert local.exists()
  211. client.disconnect()
  212. def test_zero_byte_download_returns_false(self, ftp_client_factory, ftp_server, tmp_path):
  213. """0-byte download returns False and cleans up (regression test)."""
  214. ftp_server.add_file("cache/empty.bin", b"")
  215. local = tmp_path / "empty.bin"
  216. client = ftp_client_factory()
  217. client.connect()
  218. result = client.download_to_file("/cache/empty.bin", local)
  219. assert result is False
  220. assert not local.exists()
  221. client.disconnect()
  222. def test_download_to_file_missing_returns_false(self, ftp_client_factory, tmp_path):
  223. """Missing file returns False."""
  224. local = tmp_path / "missing.bin"
  225. client = ftp_client_factory()
  226. client.connect()
  227. result = client.download_to_file("/cache/no_such_file.bin", local)
  228. assert result is False
  229. client.disconnect()
  230. def test_download_large_file(self, ftp_client_factory, ftp_server):
  231. """Large file download (>1MB) works correctly."""
  232. large_content = b"X" * (1024 * 1024 + 500) # ~1MB + 500 bytes
  233. ftp_server.add_file("cache/large.bin", large_content)
  234. client = ftp_client_factory()
  235. client.connect()
  236. result = client.download_file("/cache/large.bin")
  237. assert result == large_content
  238. client.disconnect()
  239. def test_download_not_connected(self):
  240. """download_file() returns None when not connected."""
  241. client = BambuFTPClient("127.0.0.1", "12345678")
  242. assert client.download_file("/cache/test.bin") is None
  243. # ---------------------------------------------------------------------------
  244. # TestUpload
  245. # ---------------------------------------------------------------------------
  246. class TestUpload:
  247. """Tests for file upload operations."""
  248. def test_upload_success(self, ftp_client_factory, ftp_server, tmp_path):
  249. """Successful upload via transfercmd (not storbinary)."""
  250. content = b"Upload test content"
  251. local = tmp_path / "upload.3mf"
  252. local.write_bytes(content)
  253. client = ftp_client_factory()
  254. client.connect()
  255. result = client.upload_file(local, "/cache/upload.3mf")
  256. assert result is True
  257. client.disconnect()
  258. # Verify via fresh connection (upload_file skips voidresp()
  259. # so the original session can't be reused for download)
  260. time.sleep(_UPLOAD_FLUSH_DELAY)
  261. client2 = ftp_client_factory()
  262. client2.connect()
  263. downloaded = client2.download_file("/cache/upload.3mf")
  264. assert downloaded == content
  265. client2.disconnect()
  266. def test_upload_progress_callback(self, ftp_client_factory, ftp_server, tmp_path):
  267. """Progress callback receives updates during upload."""
  268. content = b"P" * 2048
  269. local = tmp_path / "progress.bin"
  270. local.write_bytes(content)
  271. progress_calls = []
  272. def on_progress(uploaded, total):
  273. progress_calls.append((uploaded, total))
  274. client = ftp_client_factory()
  275. client.connect()
  276. client.upload_file(local, "/cache/progress.bin", on_progress)
  277. assert len(progress_calls) >= 1
  278. # Last call should report full file uploaded
  279. assert progress_calls[-1][0] == len(content)
  280. assert progress_calls[-1][1] == len(content)
  281. client.disconnect()
  282. def test_upload_not_connected(self, tmp_path):
  283. """Upload when not connected returns False."""
  284. local = tmp_path / "test.bin"
  285. local.write_bytes(b"data")
  286. client = BambuFTPClient("127.0.0.1", "12345678")
  287. assert client.upload_file(local, "/cache/test.bin") is False
  288. def test_upload_553_no_sd_card(self, ftp_client_factory, ftp_server, tmp_path):
  289. """553 error (no SD card) returns False."""
  290. ftp_server.inject_failure("STOR", 553, "Could not create file.")
  291. local = tmp_path / "test.bin"
  292. local.write_bytes(b"data")
  293. client = ftp_client_factory()
  294. client.connect()
  295. result = client.upload_file(local, "/cache/test.bin")
  296. assert result is False
  297. client.disconnect()
  298. def test_upload_550_permission_denied(self, ftp_client_factory, ftp_server, tmp_path):
  299. """550 error (permission denied) returns False."""
  300. ftp_server.inject_failure("STOR", 550, "Permission denied.")
  301. local = tmp_path / "test.bin"
  302. local.write_bytes(b"data")
  303. client = ftp_client_factory()
  304. client.connect()
  305. result = client.upload_file(local, "/cache/test.bin")
  306. assert result is False
  307. client.disconnect()
  308. def test_upload_552_storage_full(self, ftp_client_factory, ftp_server, tmp_path):
  309. """552 error (storage full) returns False."""
  310. ftp_server.inject_failure("STOR", 552, "Storage quota exceeded.")
  311. local = tmp_path / "test.bin"
  312. local.write_bytes(b"data")
  313. client = ftp_client_factory()
  314. client.connect()
  315. result = client.upload_file(local, "/cache/test.bin")
  316. assert result is False
  317. client.disconnect()
  318. def test_upload_bytes_success(self, ftp_client_factory, ftp_server):
  319. """upload_bytes() writes data to server."""
  320. data = b"Bytes upload content"
  321. client = ftp_client_factory()
  322. client.connect()
  323. result = client.upload_bytes(data, "/cache/bytes.bin")
  324. assert result is True
  325. client.disconnect()
  326. # Verify via fresh connection
  327. time.sleep(_UPLOAD_FLUSH_DELAY)
  328. client2 = ftp_client_factory()
  329. client2.connect()
  330. downloaded = client2.download_file("/cache/bytes.bin")
  331. assert downloaded == data
  332. client2.disconnect()
  333. def test_upload_bytes_failure(self, ftp_client_factory, ftp_server):
  334. """upload_bytes() returns False on STOR failure."""
  335. ftp_server.inject_failure("STOR", 553, "No space.")
  336. client = ftp_client_factory()
  337. client.connect()
  338. result = client.upload_bytes(b"data", "/cache/fail.bin")
  339. assert result is False
  340. client.disconnect()
  341. def test_upload_large_chunked(self, ftp_client_factory, ftp_server, tmp_path):
  342. """Large file upload in chunks completes without error.
  343. Uses 2.5MB to trigger multiple chunks with 1MB CHUNK_SIZE.
  344. Content verification skipped because upload_file() doesn't call
  345. voidresp() (for A1 compatibility), so the server may still be
  346. flushing when we check. The upload result=True confirms the
  347. client sent all chunks without error.
  348. """
  349. content = b"C" * (1024 * 1024 * 2 + 512 * 1024)
  350. local = tmp_path / "large.bin"
  351. local.write_bytes(content)
  352. progress_calls = []
  353. def on_progress(uploaded, total):
  354. progress_calls.append((uploaded, total))
  355. client = ftp_client_factory()
  356. client.connect()
  357. result = client.upload_file(local, "/cache/large.bin", on_progress)
  358. assert result is True
  359. # Verify multiple chunks were sent
  360. assert len(progress_calls) >= 3 # 2.5MB / 1MB = at least 3 chunks
  361. assert progress_calls[-1][0] == len(content)
  362. client.disconnect()
  363. # ---------------------------------------------------------------------------
  364. # TestDelete
  365. # ---------------------------------------------------------------------------
  366. class TestDelete:
  367. """Tests for file deletion."""
  368. def test_delete_success(self, ftp_client_factory, ftp_server):
  369. """Successful file deletion."""
  370. ftp_server.add_file("cache/to_delete.bin", b"delete me")
  371. client = ftp_client_factory()
  372. client.connect()
  373. result = client.delete_file("/cache/to_delete.bin")
  374. assert result is True
  375. assert not ftp_server.file_exists("cache/to_delete.bin")
  376. client.disconnect()
  377. def test_delete_not_found(self, ftp_client_factory):
  378. """Deleting a nonexistent file returns False."""
  379. client = ftp_client_factory()
  380. client.connect()
  381. result = client.delete_file("/cache/no_such_file.bin")
  382. assert result is False
  383. client.disconnect()
  384. def test_delete_not_connected(self):
  385. """Delete when not connected returns False."""
  386. client = BambuFTPClient("127.0.0.1", "12345678")
  387. assert client.delete_file("/cache/test.bin") is False
  388. # ---------------------------------------------------------------------------
  389. # TestFileSize
  390. # ---------------------------------------------------------------------------
  391. class TestFileSize:
  392. """Tests for get_file_size."""
  393. def test_file_size_correct(self, ftp_client_factory, ftp_server):
  394. """Returns correct file size."""
  395. ftp_server.add_file("cache/sized.bin", b"a" * 4096)
  396. client = ftp_client_factory()
  397. client.connect()
  398. size = client.get_file_size("/cache/sized.bin")
  399. assert size == 4096
  400. client.disconnect()
  401. def test_file_size_missing(self, ftp_client_factory):
  402. """Returns None for missing file."""
  403. client = ftp_client_factory()
  404. client.connect()
  405. size = client.get_file_size("/cache/no_file.bin")
  406. assert size is None
  407. client.disconnect()
  408. def test_file_size_not_connected(self):
  409. """Returns None when not connected."""
  410. client = BambuFTPClient("127.0.0.1", "12345678")
  411. assert client.get_file_size("/cache/test.bin") is None
  412. # ---------------------------------------------------------------------------
  413. # TestStorageInfo
  414. # ---------------------------------------------------------------------------
  415. class TestStorageInfo:
  416. """Tests for storage info and diagnostics."""
  417. def test_avbl_parsed(self, ftp_client_factory, ftp_server):
  418. """AVBL response is parsed for free_bytes."""
  419. ftp_server.set_avbl_bytes(5000000000)
  420. client = ftp_client_factory()
  421. client.connect()
  422. info = client.get_storage_info()
  423. assert info is not None
  424. assert info["free_bytes"] == 5000000000
  425. client.disconnect()
  426. def test_used_bytes_from_scan(self, ftp_client_factory, ftp_server):
  427. """used_bytes calculated from directory scan."""
  428. ftp_server.add_file("cache/file1.bin", b"a" * 1000)
  429. ftp_server.add_file("cache/file2.bin", b"b" * 2000)
  430. client = ftp_client_factory()
  431. client.connect()
  432. info = client.get_storage_info()
  433. assert info is not None
  434. assert info["used_bytes"] >= 3000 # At least these two files
  435. client.disconnect()
  436. def test_storage_info_not_connected(self):
  437. """Returns None when not connected."""
  438. client = BambuFTPClient("127.0.0.1", "12345678")
  439. assert client.get_storage_info() is None
  440. def test_diagnose_storage_success(self, ftp_client_factory, ftp_server):
  441. """diagnose_storage() returns connected=True with working diagnostics."""
  442. client = ftp_client_factory()
  443. client.connect()
  444. diag = client.diagnose_storage()
  445. assert diag["connected"] is True
  446. assert diag["can_list_root"] is True
  447. assert diag["can_list_cache"] is True
  448. assert diag["pwd"] is not None
  449. assert diag["storage_info"] is not None
  450. client.disconnect()
  451. def test_diagnose_storage_not_connected(self):
  452. """diagnose_storage() reports not connected."""
  453. client = BambuFTPClient("127.0.0.1", "12345678")
  454. diag = client.diagnose_storage()
  455. assert diag["connected"] is False
  456. assert "FTP not connected" in diag["errors"]
  457. # ---------------------------------------------------------------------------
  458. # TestModelSpecificBehavior
  459. # ---------------------------------------------------------------------------
  460. class TestModelSpecificBehavior:
  461. """Tests for printer model-specific FTP behavior."""
  462. def test_x1c_upload(self, ftp_client_factory, ftp_server, tmp_path):
  463. """X1C upload with session reuse succeeds."""
  464. content = b"X1C upload data"
  465. local = tmp_path / "x1c.3mf"
  466. local.write_bytes(content)
  467. client = ftp_client_factory(printer_model="X1C")
  468. client.connect()
  469. result = client.upload_file(local, "/cache/x1c.3mf")
  470. assert result is True
  471. client.disconnect()
  472. # Verify via fresh connection
  473. time.sleep(_UPLOAD_FLUSH_DELAY)
  474. client2 = ftp_client_factory(printer_model="X1C")
  475. client2.connect()
  476. downloaded = client2.download_file("/cache/x1c.3mf")
  477. assert downloaded == content
  478. client2.disconnect()
  479. def test_a1_upload_prot_c(self, ftp_client_factory, ftp_server, tmp_path):
  480. """A1 model upload with prot_c succeeds."""
  481. content = b"A1 upload data"
  482. local = tmp_path / "a1.3mf"
  483. local.write_bytes(content)
  484. client = ftp_client_factory(printer_model="A1", force_prot_c=True)
  485. client.connect()
  486. result = client.upload_file(local, "/cache/a1.3mf")
  487. assert result is True
  488. client.disconnect()
  489. # Verify via fresh connection
  490. time.sleep(_UPLOAD_FLUSH_DELAY)
  491. client2 = ftp_client_factory(printer_model="A1", force_prot_c=True)
  492. client2.connect()
  493. downloaded = client2.download_file("/cache/a1.3mf")
  494. assert downloaded == content
  495. client2.disconnect()
  496. def test_a1_mini_upload(self, ftp_client_factory, ftp_server, tmp_path):
  497. """A1 Mini model upload succeeds."""
  498. content = b"A1 Mini data"
  499. local = tmp_path / "a1mini.3mf"
  500. local.write_bytes(content)
  501. client = ftp_client_factory(printer_model="A1 Mini", force_prot_c=True)
  502. client.connect()
  503. result = client.upload_file(local, "/cache/a1mini.3mf")
  504. assert result is True
  505. client.disconnect()
  506. def test_p1s_upload(self, ftp_client_factory, ftp_server, tmp_path):
  507. """P1S model upload with session reuse succeeds."""
  508. content = b"P1S upload data"
  509. local = tmp_path / "p1s.3mf"
  510. local.write_bytes(content)
  511. client = ftp_client_factory(printer_model="P1S")
  512. client.connect()
  513. result = client.upload_file(local, "/cache/p1s.3mf")
  514. assert result is True
  515. client.disconnect()
  516. def test_unknown_model_defaults_prot_p(self, ftp_client_factory):
  517. """Unknown model defaults to prot_p."""
  518. client = ftp_client_factory(printer_model="FuturePrinter3000")
  519. assert client._is_a1_model() is False
  520. assert client._should_use_prot_c() is False
  521. assert client.connect() is True
  522. client.disconnect()
  523. def test_mode_cache_persists_and_clears(self, ftp_client_factory):
  524. """Mode cache works within a test and clears between tests."""
  525. # Cache should be empty at start (autouse fixture clears it)
  526. assert BambuFTPClient._mode_cache == {}
  527. # Connect and cache a mode
  528. BambuFTPClient.cache_mode("127.0.0.1", "prot_p")
  529. assert BambuFTPClient._mode_cache["127.0.0.1"] == "prot_p"
  530. # New client for same IP uses cached mode
  531. client = ftp_client_factory(printer_model="A1")
  532. assert client._get_cached_mode() == "prot_p"
  533. assert client._should_use_prot_c() is False
  534. client.disconnect()
  535. # ---------------------------------------------------------------------------
  536. # TestAsyncWrappers
  537. # ---------------------------------------------------------------------------
  538. class TestAsyncWrappers:
  539. """Tests for async wrapper functions using patch_ftp_port fixture."""
  540. @pytest.mark.asyncio
  541. async def test_upload_file_async_success(self, patch_ftp_port, tmp_path):
  542. """upload_file_async succeeds for X1C."""
  543. content = b"async upload"
  544. local = tmp_path / "async_up.3mf"
  545. local.write_bytes(content)
  546. result = await upload_file_async(
  547. "127.0.0.1",
  548. "12345678",
  549. local,
  550. "/cache/async_up.3mf",
  551. timeout=30.0,
  552. printer_model="X1C",
  553. )
  554. assert result is True
  555. @pytest.mark.asyncio
  556. async def test_upload_file_async_a1_fallback(self, patch_ftp_port, tmp_path):
  557. """upload_file_async tries prot_p then falls back to prot_c for A1."""
  558. content = b"a1 async upload"
  559. local = tmp_path / "a1_async.3mf"
  560. local.write_bytes(content)
  561. # For A1 models, if prot_p succeeds we get True.
  562. # If prot_p fails, it tries prot_c. Either way should succeed
  563. # against our mock server which accepts both.
  564. result = await upload_file_async(
  565. "127.0.0.1",
  566. "12345678",
  567. local,
  568. "/cache/a1_async.3mf",
  569. timeout=30.0,
  570. printer_model="A1",
  571. )
  572. assert result is True
  573. @pytest.mark.asyncio
  574. async def test_download_file_async_success(self, patch_ftp_port, tmp_path):
  575. """download_file_async succeeds."""
  576. server = patch_ftp_port
  577. content = b"async download content"
  578. server.add_file("cache/async_dl.bin", content)
  579. local = tmp_path / "async_dl.bin"
  580. result = await download_file_async(
  581. "127.0.0.1",
  582. "12345678",
  583. "/cache/async_dl.bin",
  584. local,
  585. timeout=30.0,
  586. printer_model="X1C",
  587. )
  588. assert result is True
  589. assert local.read_bytes() == content
  590. @pytest.mark.asyncio
  591. async def test_download_file_async_a1_fallback(self, patch_ftp_port, tmp_path):
  592. """download_file_async falls back for A1 models."""
  593. server = patch_ftp_port
  594. server.add_file("cache/a1_dl.bin", b"a1 data")
  595. local = tmp_path / "a1_dl.bin"
  596. result = await download_file_async(
  597. "127.0.0.1",
  598. "12345678",
  599. "/cache/a1_dl.bin",
  600. local,
  601. timeout=30.0,
  602. printer_model="A1",
  603. )
  604. assert result is True
  605. @pytest.mark.asyncio
  606. async def test_download_file_try_paths_first_succeeds(self, patch_ftp_port, tmp_path):
  607. """download_file_try_paths_async succeeds on first path."""
  608. server = patch_ftp_port
  609. server.add_file("cache/try1.bin", b"first path")
  610. local = tmp_path / "try.bin"
  611. result = await download_file_try_paths_async(
  612. "127.0.0.1",
  613. "12345678",
  614. ["/cache/try1.bin", "/cache/try2.bin"],
  615. local,
  616. printer_model="X1C",
  617. )
  618. assert result is True
  619. assert local.read_bytes() == b"first path"
  620. @pytest.mark.asyncio
  621. async def test_download_file_try_paths_fallback(self, patch_ftp_port, tmp_path):
  622. """download_file_try_paths_async falls back to second path."""
  623. server = patch_ftp_port
  624. server.add_file("cache/second.bin", b"second path")
  625. local = tmp_path / "fallback.bin"
  626. result = await download_file_try_paths_async(
  627. "127.0.0.1",
  628. "12345678",
  629. ["/cache/missing.bin", "/cache/second.bin"],
  630. local,
  631. printer_model="X1C",
  632. )
  633. assert result is True
  634. assert local.read_bytes() == b"second path"
  635. @pytest.mark.asyncio
  636. async def test_list_files_async_success(self, patch_ftp_port):
  637. """list_files_async returns file list."""
  638. server = patch_ftp_port
  639. server.add_file("cache/listed.bin", b"data")
  640. result = await list_files_async(
  641. "127.0.0.1",
  642. "12345678",
  643. "/cache",
  644. timeout=30.0,
  645. printer_model="X1C",
  646. )
  647. names = {f["name"] for f in result}
  648. assert "listed.bin" in names
  649. @pytest.mark.asyncio
  650. async def test_delete_file_async_success(self, patch_ftp_port):
  651. """delete_file_async deletes a file."""
  652. server = patch_ftp_port
  653. server.add_file("cache/to_async_del.bin", b"delete me")
  654. result = await delete_file_async(
  655. "127.0.0.1",
  656. "12345678",
  657. "/cache/to_async_del.bin",
  658. printer_model="X1C",
  659. )
  660. assert result is True
  661. assert not server.file_exists("cache/to_async_del.bin")
  662. # ---------------------------------------------------------------------------
  663. # TestFailureScenarios
  664. # ---------------------------------------------------------------------------
  665. class TestFailureScenarios:
  666. """Regression tests for known FTP failure modes."""
  667. def test_550_caught_by_broad_except(self, ftp_client_factory, ftp_server, tmp_path):
  668. """550 error_perm is caught by (OSError, ftplib.Error) handler.
  669. Regression: error_perm is a subclass of ftplib.Error, so the
  670. broad except clause in upload_file catches it correctly.
  671. """
  672. ftp_server.inject_failure("STOR", 550, "Permission denied.")
  673. local = tmp_path / "test.bin"
  674. local.write_bytes(b"data")
  675. client = ftp_client_factory()
  676. client.connect()
  677. result = client.upload_file(local, "/cache/test.bin")
  678. assert result is False
  679. client.disconnect()
  680. def test_zero_byte_download_detected(self, ftp_client_factory, ftp_server, tmp_path):
  681. """0-byte download is detected and file is cleaned up.
  682. Regression: Prior to fix, 0-byte downloads were reported as success.
  683. """
  684. ftp_server.add_file("cache/zero.bin", b"")
  685. local = tmp_path / "zero.bin"
  686. client = ftp_client_factory()
  687. client.connect()
  688. result = client.download_to_file("/cache/zero.bin", local)
  689. assert result is False
  690. assert not local.exists()
  691. client.disconnect()
  692. def test_connection_refused_handled(self):
  693. """Connection refused is handled gracefully."""
  694. client = BambuFTPClient("127.0.0.1", "12345678", timeout=2.0)
  695. client.FTP_PORT = 1 # Almost certainly not listening
  696. assert client.connect() is False
  697. def test_auth_failure_530(self, ftp_client_factory, ftp_server):
  698. """530 authentication failure returns False."""
  699. ftp_server.inject_failure("PASS", 530, "Login incorrect.")
  700. client = ftp_client_factory()
  701. result = client.connect()
  702. assert result is False
  703. def test_retr_550_handled(self, ftp_client_factory, ftp_server):
  704. """RETR 550 (file not found) returns None."""
  705. ftp_server.inject_failure("RETR", 550, "File not found.")
  706. ftp_server.add_file("cache/exists.bin", b"data")
  707. client = ftp_client_factory()
  708. client.connect()
  709. result = client.download_file("/cache/exists.bin")
  710. assert result is None
  711. client.disconnect()
  712. def test_cwd_550_handled(self, ftp_client_factory, ftp_server):
  713. """CWD 550 is handled in list_files."""
  714. ftp_server.inject_failure("CWD", 550, "Directory not found.")
  715. client = ftp_client_factory()
  716. client.connect()
  717. result = client.list_files("/nonexistent")
  718. assert result == []
  719. client.disconnect()
  720. def test_stor_553_handled(self, ftp_client_factory, ftp_server, tmp_path):
  721. """STOR 553 (no SD card) handled gracefully."""
  722. ftp_server.inject_failure("STOR", 553, "Could not create file.")
  723. local = tmp_path / "test.bin"
  724. local.write_bytes(b"test")
  725. client = ftp_client_factory()
  726. client.connect()
  727. result = client.upload_file(local, "/cache/test.bin")
  728. assert result is False
  729. client.disconnect()
  730. def test_diagnose_storage_cwd_failure_doesnt_propagate(self, ftp_client_factory, ftp_server):
  731. """diagnose_storage CWD failure doesn't crash the whole operation.
  732. Regression: diagnose_storage() was called in the upload path and
  733. a CWD failure would propagate and crash the upload.
  734. """
  735. ftp_server.inject_failure("CWD", 550, "No such directory.", count=2)
  736. client = ftp_client_factory()
  737. client.connect()
  738. diag = client.diagnose_storage()
  739. # Should still return results (with errors noted)
  740. assert diag["connected"] is True
  741. assert len(diag["errors"]) > 0
  742. client.disconnect()
  743. def test_failure_injection_count_decrements(self, ftp_client_factory, ftp_server):
  744. """Failure injection with count decrements and eventually succeeds."""
  745. ftp_server.add_file("cache/retry.bin", b"data after retry")
  746. ftp_server.inject_failure("RETR", 550, "Temporary error.", count=1)
  747. client = ftp_client_factory()
  748. client.connect()
  749. # First attempt fails
  750. result1 = client.download_file("/cache/retry.bin")
  751. assert result1 is None
  752. # Second attempt succeeds (failure count exhausted)
  753. result2 = client.download_file("/cache/retry.bin")
  754. assert result2 == b"data after retry"
  755. client.disconnect()