test_bambu_ftp.py 50 KB

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