test_printer_media.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411
  1. import ast
  2. import asyncio
  3. import os
  4. import shutil
  5. import threading
  6. import time
  7. import zipfile
  8. from datetime import datetime
  9. from pathlib import Path
  10. from types import SimpleNamespace
  11. from unittest.mock import AsyncMock, patch
  12. import pytest
  13. from backend.app.core.config import settings
  14. from backend.app.services import printer_media
  15. from backend.app.services.printer_media import (
  16. MAX_PRINTER_ZIP_BYTES,
  17. PrinterFilesZipInsufficientSpaceError,
  18. PrinterFilesZipTooLargeError,
  19. build_printer_files_zip,
  20. match_ipcam_chunks,
  21. prune_stale_printer_file_bundles,
  22. remove_printer_files_zip,
  23. )
  24. def test_module_imports_on_every_supported_platform():
  25. """No POSIX-only import may sit at the top of this module.
  26. Bambuddy ships a signed Windows installer, and printers.py imports this
  27. module at startup, so a top-level ``import fcntl`` here is not a degraded
  28. feature on Windows -- it is an application that does not boot at all.
  29. network_utils.py is the house pattern: import inside the branch that needs
  30. it, after checking ``sys.platform``.
  31. """
  32. tree = ast.parse(Path(printer_media.__file__).read_text(encoding="utf-8"))
  33. top_level = {
  34. alias.name.split(".")[0] for node in tree.body if isinstance(node, ast.Import) for alias in node.names
  35. } | {node.module.split(".")[0] for node in tree.body if isinstance(node, ast.ImportFrom) and node.module}
  36. assert not top_level & {"fcntl", "termios", "pwd", "grp", "resource", "syslog"}
  37. def test_match_ipcam_chunks_uses_archive_window_and_ignores_non_video_entries():
  38. files = [
  39. {"name": "index", "mtime": datetime(2026, 8, 12, 10, 5), "is_directory": False},
  40. {"name": "ipcam-record.before.mp4", "mtime": datetime(2026, 8, 12, 9, 50), "is_directory": False},
  41. {"name": "ipcam-record.first.mp4", "mtime": datetime(2026, 8, 12, 10, 4), "is_directory": False},
  42. {"name": "ipcam-record.last.mp4", "mtime": datetime(2026, 8, 12, 11, 8), "is_directory": False},
  43. {"name": "ipcam-record.after.mp4", "mtime": datetime(2026, 8, 12, 11, 11), "is_directory": False},
  44. ]
  45. matched = match_ipcam_chunks(
  46. files,
  47. datetime(2026, 8, 12, 10, 0),
  48. datetime(2026, 8, 12, 11, 0),
  49. )
  50. assert [file["name"] for file in matched] == ["ipcam-record.first.mp4", "ipcam-record.last.mp4"]
  51. @pytest.mark.asyncio
  52. async def test_build_printer_files_zip_stages_on_data_volume_and_compresses_by_type(tmp_path, monkeypatch):
  53. printer = SimpleNamespace(ip_address="printer", access_code="code", model="X1C")
  54. monkeypatch.setattr(settings, "archive_dir", tmp_path / "archive")
  55. payloads = {
  56. "/ipcam/chunk.mp4": (b"video-") * 512,
  57. "/cache/model.gcode": (b"G1 X1 Y1\n") * 512,
  58. }
  59. async def fake_download(_ip, _code, remote_path, local_path: Path, **_kwargs):
  60. local_path.write_bytes(payloads[remote_path])
  61. return True
  62. with patch(
  63. "backend.app.services.printer_media.download_file_async",
  64. new=AsyncMock(side_effect=fake_download),
  65. ):
  66. result = await build_printer_files_zip(
  67. printer,
  68. ["/ipcam/chunk.mp4", "/cache/model.gcode"],
  69. {path: len(payload) for path, payload in payloads.items()},
  70. )
  71. zip_path = result.path
  72. try:
  73. assert result.successful == 2
  74. assert zip_path.is_relative_to(settings.archive_dir / "temp" / "printer-file-downloads")
  75. with zipfile.ZipFile(zip_path) as archive:
  76. assert archive.namelist() == ["ipcam/chunk.mp4", "cache/model.gcode"]
  77. assert archive.getinfo("ipcam/chunk.mp4").compress_type == zipfile.ZIP_STORED
  78. assert archive.getinfo("cache/model.gcode").compress_type == zipfile.ZIP_DEFLATED
  79. assert not list(zip_path.parent.glob("download-*"))
  80. finally:
  81. remove_printer_files_zip(zip_path)
  82. assert not zip_path.parent.exists()
  83. @pytest.mark.asyncio
  84. async def test_build_printer_files_zip_offloads_blocking_zip_and_filesystem_work(tmp_path, monkeypatch):
  85. printer = SimpleNamespace(ip_address="printer", access_code="code", model="X1C")
  86. monkeypatch.setattr(settings, "archive_dir", tmp_path / "archive")
  87. event_loop_thread = threading.get_ident()
  88. offloaded_threads: list[int] = []
  89. real_prune = printer_media._prune_stale_bundles
  90. real_space_check = printer_media._check_initial_space
  91. def tracking_prune(root):
  92. offloaded_threads.append(threading.get_ident())
  93. return real_prune(root)
  94. def tracking_space_check(root, sizes):
  95. offloaded_threads.append(threading.get_ident())
  96. return real_space_check(root, sizes)
  97. async def fake_download(_ip, _code, _remote_path, local_path: Path, **_kwargs):
  98. local_path.write_bytes(b"G1 X1 Y1\n" * 512)
  99. return True
  100. monkeypatch.setattr(printer_media, "_prune_stale_bundles", tracking_prune)
  101. monkeypatch.setattr(printer_media, "_check_initial_space", tracking_space_check)
  102. with patch("backend.app.services.printer_media.download_file_async", new=AsyncMock(side_effect=fake_download)):
  103. result = await build_printer_files_zip(printer, ["/model.gcode"], {"/model.gcode": 4608})
  104. try:
  105. assert offloaded_threads
  106. assert all(thread_id != event_loop_thread for thread_id in offloaded_threads)
  107. finally:
  108. remove_printer_files_zip(result.path)
  109. @pytest.mark.asyncio
  110. async def test_prune_stale_printer_file_bundles_removes_hour_old_abandoned_bundle(tmp_path, monkeypatch):
  111. monkeypatch.setattr(settings, "archive_dir", tmp_path / "archive")
  112. root = settings.archive_dir / "temp" / "printer-file-downloads"
  113. stale = root / "stale"
  114. fresh = root / "fresh"
  115. stale.mkdir(parents=True)
  116. fresh.mkdir()
  117. (stale / "printer-files.zip").write_bytes(b"stale")
  118. (fresh / "printer-files.zip").write_bytes(b"fresh")
  119. old = time.time() - 60 * 60 - 1
  120. os.utime(stale, (old, old))
  121. await prune_stale_printer_file_bundles()
  122. assert not stale.exists()
  123. assert fresh.exists()
  124. @pytest.mark.asyncio
  125. async def test_build_printer_files_zip_skips_relative_and_nul_paths(tmp_path, monkeypatch):
  126. printer = SimpleNamespace(ip_address="printer", access_code="code", model="X1C")
  127. monkeypatch.setattr(settings, "archive_dir", tmp_path / "archive")
  128. async def fake_download(_ip, _code, remote_path, local_path: Path, **_kwargs):
  129. local_path.write_bytes(b"valid")
  130. return True
  131. download = AsyncMock(side_effect=fake_download)
  132. with patch("backend.app.services.printer_media.download_file_async", new=download):
  133. result = await build_printer_files_zip(
  134. printer,
  135. ["relative.gcode", "/bad\x00.gcode", "/valid.gcode"],
  136. {"relative.gcode": 1, "/bad\x00.gcode": 1, "/valid.gcode": 5},
  137. )
  138. try:
  139. assert result.requested == 3
  140. assert result.successful == 1
  141. assert result.failed_paths == ("relative.gcode", "/bad\x00.gcode")
  142. download.assert_awaited_once()
  143. assert download.await_args.args[2] == "/valid.gcode"
  144. finally:
  145. remove_printer_files_zip(result.path)
  146. @pytest.mark.asyncio
  147. async def test_build_printer_files_zip_rejects_oversized_selection_before_download(tmp_path, monkeypatch):
  148. printer = SimpleNamespace(ip_address="printer", access_code="code", model="X1C")
  149. monkeypatch.setattr(settings, "archive_dir", tmp_path / "archive")
  150. download = AsyncMock()
  151. with (
  152. patch("backend.app.services.printer_media.download_file_async", new=download),
  153. pytest.raises(PrinterFilesZipTooLargeError),
  154. ):
  155. await build_printer_files_zip(
  156. printer,
  157. ["/huge.mp4"],
  158. {"/huge.mp4": MAX_PRINTER_ZIP_BYTES + 1},
  159. )
  160. download.assert_not_awaited()
  161. @pytest.mark.asyncio
  162. async def test_build_printer_files_zip_rejects_insufficient_data_volume_space(tmp_path, monkeypatch):
  163. printer = SimpleNamespace(ip_address="printer", access_code="code", model="X1C")
  164. monkeypatch.setattr(settings, "archive_dir", tmp_path / "archive")
  165. monkeypatch.setattr("backend.app.services.printer_media.shutil.disk_usage", lambda _path: SimpleNamespace(free=1))
  166. with pytest.raises(PrinterFilesZipInsufficientSpaceError):
  167. await build_printer_files_zip(printer, ["/small.gcode"], {"/small.gcode": 5})
  168. @pytest.mark.asyncio
  169. async def test_build_printer_files_zip_keeps_a_file_whose_listing_size_went_stale(tmp_path, monkeypatch):
  170. """A verified transfer is not re-judged against the browser's size hint.
  171. download_to_file compares what it wrote against the printer's own SIZE and
  172. treats that as the authority, precisely because it beats a hint the browser
  173. round-tripped. The hint goes stale in the case this feature exists for -- an
  174. /ipcam chunk still being written when the modal listed it -- and the file
  175. then arrives longer than advertised. Dropping it as "truncated" would fail
  176. the one selection the user came for; a genuinely short RETR is already
  177. rejected a layer down (test_bambu_ftp.py).
  178. """
  179. printer = SimpleNamespace(ip_address="printer", access_code="code", model="X1C")
  180. monkeypatch.setattr(settings, "archive_dir", tmp_path / "archive")
  181. async def fake_download(_ip, _code, _remote_path, local_path: Path, **_kwargs):
  182. # The chunk grew by 20 bytes between the listing and the transfer.
  183. local_path.write_bytes(b"A" * 120)
  184. return True
  185. with patch("backend.app.services.printer_media.download_file_async", new=AsyncMock(side_effect=fake_download)):
  186. result = await build_printer_files_zip(printer, ["/ipcam/chunk.mp4"], {"/ipcam/chunk.mp4": 100})
  187. try:
  188. assert (result.successful, result.failed_paths) == (1, ())
  189. with zipfile.ZipFile(result.path) as archive:
  190. assert archive.read("ipcam/chunk.mp4") == b"A" * 120
  191. finally:
  192. remove_printer_files_zip(result.path)
  193. def test_match_ipcam_chunks_caps_an_unfinished_archive_window():
  194. files = [
  195. {
  196. "name": "ipcam-record.next-week.mp4",
  197. "mtime": datetime(2026, 8, 20, 10, 0),
  198. "is_directory": False,
  199. }
  200. ]
  201. assert (
  202. match_ipcam_chunks(
  203. files,
  204. datetime(2026, 8, 12, 10, 0),
  205. None,
  206. now=datetime(2026, 8, 21, 10, 0),
  207. )
  208. == []
  209. )
  210. @pytest.mark.asyncio
  211. async def test_build_printer_files_zip_cleans_bundle_on_cancellation(tmp_path, monkeypatch):
  212. printer = SimpleNamespace(ip_address="printer", access_code="code", model="X1C")
  213. monkeypatch.setattr(settings, "archive_dir", tmp_path / "archive")
  214. async def cancel_download(*_args, **_kwargs):
  215. raise asyncio.CancelledError
  216. with (
  217. patch("backend.app.services.printer_media.download_file_async", new=AsyncMock(side_effect=cancel_download)),
  218. pytest.raises(asyncio.CancelledError),
  219. ):
  220. await build_printer_files_zip(printer, ["/video.mp4"], {"/video.mp4": 100})
  221. root = settings.archive_dir / "temp" / "printer-file-downloads"
  222. assert not list(root.glob("bundle-*"))
  223. @pytest.mark.asyncio
  224. async def test_build_printer_files_zip_reports_per_file_progress(tmp_path, monkeypatch):
  225. printer = SimpleNamespace(ip_address="printer", access_code="code", model="X1C")
  226. monkeypatch.setattr(settings, "archive_dir", tmp_path / "archive")
  227. progress: list[tuple[int, int]] = []
  228. async def fake_download(_ip, _code, remote_path, local_path: Path, **_kwargs):
  229. if remote_path.endswith("missing.gcode"):
  230. return False
  231. local_path.write_bytes(b"ok")
  232. return True
  233. async def report(successful: int, failed: int) -> None:
  234. progress.append((successful, failed))
  235. with patch(
  236. "backend.app.services.printer_media.download_file_async",
  237. new=AsyncMock(side_effect=fake_download),
  238. ):
  239. result = await build_printer_files_zip(
  240. printer,
  241. ["/ok.gcode", "/missing.gcode"],
  242. {"/ok.gcode": 2, "/missing.gcode": 2},
  243. progress_callback=report,
  244. )
  245. try:
  246. assert progress == [(1, 0), (1, 1)]
  247. finally:
  248. remove_printer_files_zip(result.path)
  249. @pytest.mark.asyncio
  250. async def test_two_preparations_run_at_the_same_time(tmp_path, monkeypatch):
  251. """Nothing queues one preparation behind another.
  252. An exclusive staging lock held for the length of a transfer would make one
  253. ten-gigabyte selection block every other download on the instance -- and the
  254. same code path serves the file browser's 3MF preview, so it would block that
  255. too, for as long as the selection takes.
  256. """
  257. printer = SimpleNamespace(ip_address="printer", access_code="code", model="X1C")
  258. monkeypatch.setattr(settings, "archive_dir", tmp_path / "archive")
  259. active = 0
  260. max_active = 0
  261. async def fake_download(_ip, _code, _remote_path, local_path: Path, **_kwargs):
  262. nonlocal active, max_active
  263. active += 1
  264. max_active = max(max_active, active)
  265. try:
  266. await asyncio.sleep(0.05)
  267. local_path.write_bytes(b"ok")
  268. return True
  269. finally:
  270. active -= 1
  271. with patch(
  272. "backend.app.services.printer_media.download_file_async",
  273. new=AsyncMock(side_effect=fake_download),
  274. ):
  275. first, second = await asyncio.gather(
  276. build_printer_files_zip(printer, ["/first.gcode"], {"/first.gcode": 2}),
  277. build_printer_files_zip(printer, ["/second.gcode"], {"/second.gcode": 2}),
  278. )
  279. try:
  280. assert max_active == 2
  281. finally:
  282. remove_printer_files_zip(first.path)
  283. remove_printer_files_zip(second.path)
  284. @pytest.mark.asyncio
  285. async def test_concurrent_preparations_both_stop_at_the_disk_reserve(tmp_path, monkeypatch):
  286. """With preparations running together, the reserve is what has to hold.
  287. The preflight only sees client-reported hints, and two jobs read the same
  288. free space before either has spent any of it, so neither can be the bound.
  289. The per-file check against actual bytes is, and it stops both of them
  290. without leaving a staged bundle behind.
  291. """
  292. printer = SimpleNamespace(ip_address="printer", access_code="code", model="X1C")
  293. archive_dir = tmp_path / "archive"
  294. monkeypatch.setattr(settings, "archive_dir", archive_dir)
  295. # Enough for the preflight, which is told 2 bytes; nowhere near enough for
  296. # the 10 MiB that actually arrives.
  297. monkeypatch.setattr(
  298. "backend.app.services.printer_media.shutil.disk_usage",
  299. lambda _path: SimpleNamespace(free=printer_media.PRINTER_ZIP_FREE_SPACE_RESERVE + 1024 * 1024),
  300. )
  301. async def fake_download(_ip, _code, _remote_path, local_path: Path, **_kwargs):
  302. await asyncio.sleep(0.01)
  303. local_path.write_bytes(b"A" * (10 * 1024 * 1024))
  304. return True
  305. with patch(
  306. "backend.app.services.printer_media.download_file_async",
  307. new=AsyncMock(side_effect=fake_download),
  308. ):
  309. outcomes = await asyncio.gather(
  310. build_printer_files_zip(printer, ["/first.gcode"], {"/first.gcode": 2}),
  311. build_printer_files_zip(printer, ["/second.gcode"], {"/second.gcode": 2}),
  312. return_exceptions=True,
  313. )
  314. assert all(isinstance(outcome, PrinterFilesZipInsufficientSpaceError) for outcome in outcomes), outcomes
  315. root = archive_dir / "temp" / "printer-file-downloads"
  316. assert [child for child in root.iterdir() if child.is_dir()] == []
  317. @pytest.mark.asyncio
  318. async def test_shutdown_awaits_download_jobs_and_publishes_cancellation(tmp_path, monkeypatch):
  319. monkeypatch.setattr(settings, "archive_dir", tmp_path / "archive")
  320. job_id = "shutdown-job-abcdefghijklmnop"
  321. printer_media._ensure_printer_zip_root()
  322. started = asyncio.Event()
  323. async def wait_forever():
  324. started.set()
  325. await asyncio.Event().wait()
  326. task = asyncio.create_task(wait_forever())
  327. printer_media._LOCAL_JOB_TASKS[job_id] = task
  328. await started.wait()
  329. await printer_media.stop_printer_download_cleanup()
  330. assert task.done()
  331. assert task.cancelled()
  332. assert printer_media._LOCAL_JOB_TASKS == {}
  333. assert printer_media._job_cancel_path(job_id).exists()