mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
412 lines
16 KiB
Python
412 lines
16 KiB
Python
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()
|