| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411 |
- 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()
|