import ast import asyncio import os import shutil import threading import time import zipfile from datetime import datetime from pathlib import Path from types import SimpleNamespace from unittest.mock import AsyncMock, patch import pytest from backend.app.core.config import settings from backend.app.services import printer_media from backend.app.services.printer_media import ( MAX_PRINTER_ZIP_BYTES, PrinterFilesZipInsufficientSpaceError, PrinterFilesZipTooLargeError, build_printer_files_zip, match_ipcam_chunks, prune_stale_printer_file_bundles, remove_printer_files_zip, ) def test_module_imports_on_every_supported_platform(): """No POSIX-only import may sit at the top of this module. Bambuddy ships a signed Windows installer, and printers.py imports this module at startup, so a top-level ``import fcntl`` here is not a degraded feature on Windows -- it is an application that does not boot at all. network_utils.py is the house pattern: import inside the branch that needs it, after checking ``sys.platform``. """ tree = ast.parse(Path(printer_media.__file__).read_text(encoding="utf-8")) top_level = { alias.name.split(".")[0] for node in tree.body if isinstance(node, ast.Import) for alias in node.names } | {node.module.split(".")[0] for node in tree.body if isinstance(node, ast.ImportFrom) and node.module} assert not top_level & {"fcntl", "termios", "pwd", "grp", "resource", "syslog"} def test_match_ipcam_chunks_uses_archive_window_and_ignores_non_video_entries(): files = [ {"name": "index", "mtime": datetime(2026, 8, 12, 10, 5), "is_directory": False}, {"name": "ipcam-record.before.mp4", "mtime": datetime(2026, 8, 12, 9, 50), "is_directory": False}, {"name": "ipcam-record.first.mp4", "mtime": datetime(2026, 8, 12, 10, 4), "is_directory": False}, {"name": "ipcam-record.last.mp4", "mtime": datetime(2026, 8, 12, 11, 8), "is_directory": False}, {"name": "ipcam-record.after.mp4", "mtime": datetime(2026, 8, 12, 11, 11), "is_directory": False}, ] matched = match_ipcam_chunks( files, datetime(2026, 8, 12, 10, 0), datetime(2026, 8, 12, 11, 0), ) assert [file["name"] for file in matched] == ["ipcam-record.first.mp4", "ipcam-record.last.mp4"] @pytest.mark.asyncio async def test_build_printer_files_zip_stages_on_data_volume_and_compresses_by_type(tmp_path, monkeypatch): printer = SimpleNamespace(ip_address="printer", access_code="code", model="X1C") monkeypatch.setattr(settings, "archive_dir", tmp_path / "archive") payloads = { "/ipcam/chunk.mp4": (b"video-") * 512, "/cache/model.gcode": (b"G1 X1 Y1\n") * 512, } async def fake_download(_ip, _code, remote_path, local_path: Path, **_kwargs): local_path.write_bytes(payloads[remote_path]) return True with patch( "backend.app.services.printer_media.download_file_async", new=AsyncMock(side_effect=fake_download), ): result = await build_printer_files_zip( printer, ["/ipcam/chunk.mp4", "/cache/model.gcode"], {path: len(payload) for path, payload in payloads.items()}, ) zip_path = result.path try: assert result.successful == 2 assert zip_path.is_relative_to(settings.archive_dir / "temp" / "printer-file-downloads") with zipfile.ZipFile(zip_path) as archive: assert archive.namelist() == ["ipcam/chunk.mp4", "cache/model.gcode"] assert archive.getinfo("ipcam/chunk.mp4").compress_type == zipfile.ZIP_STORED assert archive.getinfo("cache/model.gcode").compress_type == zipfile.ZIP_DEFLATED assert not list(zip_path.parent.glob("download-*")) finally: remove_printer_files_zip(zip_path) assert not zip_path.parent.exists() @pytest.mark.asyncio async def test_build_printer_files_zip_offloads_blocking_zip_and_filesystem_work(tmp_path, monkeypatch): printer = SimpleNamespace(ip_address="printer", access_code="code", model="X1C") monkeypatch.setattr(settings, "archive_dir", tmp_path / "archive") event_loop_thread = threading.get_ident() offloaded_threads: list[int] = [] real_prune = printer_media._prune_stale_bundles real_space_check = printer_media._check_initial_space def tracking_prune(root): offloaded_threads.append(threading.get_ident()) return real_prune(root) def tracking_space_check(root, sizes): offloaded_threads.append(threading.get_ident()) return real_space_check(root, sizes) async def fake_download(_ip, _code, _remote_path, local_path: Path, **_kwargs): local_path.write_bytes(b"G1 X1 Y1\n" * 512) return True monkeypatch.setattr(printer_media, "_prune_stale_bundles", tracking_prune) monkeypatch.setattr(printer_media, "_check_initial_space", tracking_space_check) with patch("backend.app.services.printer_media.download_file_async", new=AsyncMock(side_effect=fake_download)): result = await build_printer_files_zip(printer, ["/model.gcode"], {"/model.gcode": 4608}) try: assert offloaded_threads assert all(thread_id != event_loop_thread for thread_id in offloaded_threads) finally: remove_printer_files_zip(result.path) @pytest.mark.asyncio async def test_prune_stale_printer_file_bundles_removes_hour_old_abandoned_bundle(tmp_path, monkeypatch): monkeypatch.setattr(settings, "archive_dir", tmp_path / "archive") root = settings.archive_dir / "temp" / "printer-file-downloads" stale = root / "stale" fresh = root / "fresh" stale.mkdir(parents=True) fresh.mkdir() (stale / "printer-files.zip").write_bytes(b"stale") (fresh / "printer-files.zip").write_bytes(b"fresh") old = time.time() - 60 * 60 - 1 os.utime(stale, (old, old)) await prune_stale_printer_file_bundles() assert not stale.exists() assert fresh.exists() @pytest.mark.asyncio async def test_build_printer_files_zip_skips_relative_and_nul_paths(tmp_path, monkeypatch): printer = SimpleNamespace(ip_address="printer", access_code="code", model="X1C") monkeypatch.setattr(settings, "archive_dir", tmp_path / "archive") async def fake_download(_ip, _code, remote_path, local_path: Path, **_kwargs): local_path.write_bytes(b"valid") return True download = AsyncMock(side_effect=fake_download) with patch("backend.app.services.printer_media.download_file_async", new=download): result = await build_printer_files_zip( printer, ["relative.gcode", "/bad\x00.gcode", "/valid.gcode"], {"relative.gcode": 1, "/bad\x00.gcode": 1, "/valid.gcode": 5}, ) try: assert result.requested == 3 assert result.successful == 1 assert result.failed_paths == ("relative.gcode", "/bad\x00.gcode") download.assert_awaited_once() assert download.await_args.args[2] == "/valid.gcode" finally: remove_printer_files_zip(result.path) @pytest.mark.asyncio async def test_build_printer_files_zip_rejects_oversized_selection_before_download(tmp_path, monkeypatch): printer = SimpleNamespace(ip_address="printer", access_code="code", model="X1C") monkeypatch.setattr(settings, "archive_dir", tmp_path / "archive") download = AsyncMock() with ( patch("backend.app.services.printer_media.download_file_async", new=download), pytest.raises(PrinterFilesZipTooLargeError), ): await build_printer_files_zip( printer, ["/huge.mp4"], {"/huge.mp4": MAX_PRINTER_ZIP_BYTES + 1}, ) download.assert_not_awaited() @pytest.mark.asyncio async def test_build_printer_files_zip_rejects_insufficient_data_volume_space(tmp_path, monkeypatch): printer = SimpleNamespace(ip_address="printer", access_code="code", model="X1C") monkeypatch.setattr(settings, "archive_dir", tmp_path / "archive") monkeypatch.setattr("backend.app.services.printer_media.shutil.disk_usage", lambda _path: SimpleNamespace(free=1)) with pytest.raises(PrinterFilesZipInsufficientSpaceError): await build_printer_files_zip(printer, ["/small.gcode"], {"/small.gcode": 5}) @pytest.mark.asyncio async def test_build_printer_files_zip_keeps_a_file_whose_listing_size_went_stale(tmp_path, monkeypatch): """A verified transfer is not re-judged against the browser's size hint. download_to_file compares what it wrote against the printer's own SIZE and treats that as the authority, precisely because it beats a hint the browser round-tripped. The hint goes stale in the case this feature exists for -- an /ipcam chunk still being written when the modal listed it -- and the file then arrives longer than advertised. Dropping it as "truncated" would fail the one selection the user came for; a genuinely short RETR is already rejected a layer down (test_bambu_ftp.py). """ printer = SimpleNamespace(ip_address="printer", access_code="code", model="X1C") monkeypatch.setattr(settings, "archive_dir", tmp_path / "archive") async def fake_download(_ip, _code, _remote_path, local_path: Path, **_kwargs): # The chunk grew by 20 bytes between the listing and the transfer. local_path.write_bytes(b"A" * 120) return True with patch("backend.app.services.printer_media.download_file_async", new=AsyncMock(side_effect=fake_download)): result = await build_printer_files_zip(printer, ["/ipcam/chunk.mp4"], {"/ipcam/chunk.mp4": 100}) try: assert (result.successful, result.failed_paths) == (1, ()) with zipfile.ZipFile(result.path) as archive: assert archive.read("ipcam/chunk.mp4") == b"A" * 120 finally: remove_printer_files_zip(result.path) def test_match_ipcam_chunks_caps_an_unfinished_archive_window(): files = [ { "name": "ipcam-record.next-week.mp4", "mtime": datetime(2026, 8, 20, 10, 0), "is_directory": False, } ] assert ( match_ipcam_chunks( files, datetime(2026, 8, 12, 10, 0), None, now=datetime(2026, 8, 21, 10, 0), ) == [] ) @pytest.mark.asyncio async def test_build_printer_files_zip_cleans_bundle_on_cancellation(tmp_path, monkeypatch): printer = SimpleNamespace(ip_address="printer", access_code="code", model="X1C") monkeypatch.setattr(settings, "archive_dir", tmp_path / "archive") async def cancel_download(*_args, **_kwargs): raise asyncio.CancelledError with ( patch("backend.app.services.printer_media.download_file_async", new=AsyncMock(side_effect=cancel_download)), pytest.raises(asyncio.CancelledError), ): await build_printer_files_zip(printer, ["/video.mp4"], {"/video.mp4": 100}) root = settings.archive_dir / "temp" / "printer-file-downloads" assert not list(root.glob("bundle-*")) @pytest.mark.asyncio async def test_build_printer_files_zip_reports_per_file_progress(tmp_path, monkeypatch): printer = SimpleNamespace(ip_address="printer", access_code="code", model="X1C") monkeypatch.setattr(settings, "archive_dir", tmp_path / "archive") progress: list[tuple[int, int]] = [] async def fake_download(_ip, _code, remote_path, local_path: Path, **_kwargs): if remote_path.endswith("missing.gcode"): return False local_path.write_bytes(b"ok") return True async def report(successful: int, failed: int) -> None: progress.append((successful, failed)) with patch( "backend.app.services.printer_media.download_file_async", new=AsyncMock(side_effect=fake_download), ): result = await build_printer_files_zip( printer, ["/ok.gcode", "/missing.gcode"], {"/ok.gcode": 2, "/missing.gcode": 2}, progress_callback=report, ) try: assert progress == [(1, 0), (1, 1)] finally: remove_printer_files_zip(result.path) @pytest.mark.asyncio async def test_two_preparations_run_at_the_same_time(tmp_path, monkeypatch): """Nothing queues one preparation behind another. An exclusive staging lock held for the length of a transfer would make one ten-gigabyte selection block every other download on the instance -- and the same code path serves the file browser's 3MF preview, so it would block that too, for as long as the selection takes. """ printer = SimpleNamespace(ip_address="printer", access_code="code", model="X1C") monkeypatch.setattr(settings, "archive_dir", tmp_path / "archive") active = 0 max_active = 0 async def fake_download(_ip, _code, _remote_path, local_path: Path, **_kwargs): nonlocal active, max_active active += 1 max_active = max(max_active, active) try: await asyncio.sleep(0.05) local_path.write_bytes(b"ok") return True finally: active -= 1 with patch( "backend.app.services.printer_media.download_file_async", new=AsyncMock(side_effect=fake_download), ): first, second = await asyncio.gather( build_printer_files_zip(printer, ["/first.gcode"], {"/first.gcode": 2}), build_printer_files_zip(printer, ["/second.gcode"], {"/second.gcode": 2}), ) try: assert max_active == 2 finally: remove_printer_files_zip(first.path) remove_printer_files_zip(second.path) @pytest.mark.asyncio async def test_concurrent_preparations_both_stop_at_the_disk_reserve(tmp_path, monkeypatch): """With preparations running together, the reserve is what has to hold. The preflight only sees client-reported hints, and two jobs read the same free space before either has spent any of it, so neither can be the bound. The per-file check against actual bytes is, and it stops both of them without leaving a staged bundle behind. """ printer = SimpleNamespace(ip_address="printer", access_code="code", model="X1C") archive_dir = tmp_path / "archive" monkeypatch.setattr(settings, "archive_dir", archive_dir) # Enough for the preflight, which is told 2 bytes; nowhere near enough for # the 10 MiB that actually arrives. monkeypatch.setattr( "backend.app.services.printer_media.shutil.disk_usage", lambda _path: SimpleNamespace(free=printer_media.PRINTER_ZIP_FREE_SPACE_RESERVE + 1024 * 1024), ) async def fake_download(_ip, _code, _remote_path, local_path: Path, **_kwargs): await asyncio.sleep(0.01) local_path.write_bytes(b"A" * (10 * 1024 * 1024)) return True with patch( "backend.app.services.printer_media.download_file_async", new=AsyncMock(side_effect=fake_download), ): outcomes = await asyncio.gather( build_printer_files_zip(printer, ["/first.gcode"], {"/first.gcode": 2}), build_printer_files_zip(printer, ["/second.gcode"], {"/second.gcode": 2}), return_exceptions=True, ) assert all(isinstance(outcome, PrinterFilesZipInsufficientSpaceError) for outcome in outcomes), outcomes root = archive_dir / "temp" / "printer-file-downloads" assert [child for child in root.iterdir() if child.is_dir()] == [] @pytest.mark.asyncio async def test_shutdown_awaits_download_jobs_and_publishes_cancellation(tmp_path, monkeypatch): monkeypatch.setattr(settings, "archive_dir", tmp_path / "archive") job_id = "shutdown-job-abcdefghijklmnop" printer_media._ensure_printer_zip_root() started = asyncio.Event() async def wait_forever(): started.set() await asyncio.Event().wait() task = asyncio.create_task(wait_forever()) printer_media._LOCAL_JOB_TASKS[job_id] = task await started.wait() await printer_media.stop_printer_download_cleanup() assert task.done() assert task.cancelled() assert printer_media._LOCAL_JOB_TASKS == {} assert printer_media._job_cancel_path(job_id).exists()