Files
bambuddy/backend/tests/unit/test_scheduler_cleanup_library.py
T
maziggy af7874546a feat(projects): per-file print progress and complete-sets tracking (#1897)
Projects made of many distinct files that each need N prints (e.g. 13
plates x 10 sets = 130 prints) only had aggregate progress. Finding out
"how many times have I printed plate_7?" meant reading the Activity
Timeline line by line, unusable at 130 events.

Projects now take an optional Copies per File target. Every printable
file in the project's linked folders shows an X / N badge with a mini
progress bar (gray not started, amber in progress, green done), and the
progress card gains a Complete Sets bar - the minimum per-file count,
i.e. how many finished assemblies can be shipped right now. Without the
target, printable files show a plain printed-count badge.

Counting matches the aggregate project stats: completed runs only,
served by a new /projects/{id}/file-progress endpoint. Runs attribute
to a file via a new library_file_id stamp on queue-dispatched archives,
falling back to content hash and then filename for historical rows.

Also fixed: files queued from a project-linked File Manager folder now
inherit that project, so their prints count toward project statistics -
previously only prints started from the project page were attributed.

Test-harness fix along the way: the test suite's get_db override never
committed, unlike production get_db, so endpoints relying on the
request-scoped commit silently lost their writes in tests. The override
now mirrors production commit/rollback semantics.
2026-07-28 12:46:31 +02:00

288 lines
11 KiB
Python

from contextlib import ExitStack
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
import backend.app.models # noqa: F401 - populate Base.metadata
import backend.app.services.print_scheduler as scheduler_module
from backend.app.core.database import Base
from backend.app.models.archive import PrintArchive
from backend.app.models.library import LibraryFile
from backend.app.models.print_queue import PrintQueueItem
from backend.app.models.printer import Printer
from backend.app.services.print_scheduler import PrintScheduler
@pytest.fixture
async def queue_factory(tmp_path):
engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
session_maker = async_sessionmaker(engine, expire_on_commit=False)
case_counter = 0
async def make_case(*, cleanup=True, is_external=False, thumbnail_path=None):
nonlocal case_counter
case_counter += 1
base_dir = tmp_path / f"case-{case_counter}"
base_dir.mkdir()
source_path = base_dir / "library" / f"source-{case_counter}.3mf"
source_path.parent.mkdir()
source_path.write_bytes(b"library source")
thumbnail_actual_path = None
thumbnail_db_path = None
if thumbnail_path == "relative":
thumbnail_db_path = f"thumbs/preview-{case_counter}.png"
thumbnail_actual_path = base_dir / thumbnail_db_path
elif thumbnail_path == "absolute":
thumbnail_actual_path = tmp_path / f"absolute-preview-{case_counter}.png"
thumbnail_db_path = str(thumbnail_actual_path)
elif thumbnail_path is not None:
thumbnail_actual_path = Path(thumbnail_path)
thumbnail_db_path = str(thumbnail_path)
if thumbnail_actual_path:
thumbnail_actual_path.parent.mkdir(parents=True, exist_ok=True)
thumbnail_actual_path.write_bytes(b"thumbnail")
async with session_maker() as db:
printer = Printer(
name=f"Printer {case_counter}",
serial_number=f"SERIAL-{case_counter}",
ip_address="127.0.0.1",
access_code="access-code",
model="X1C",
)
library_file = LibraryFile(
filename=f"source-{case_counter}.3mf",
file_path=str(source_path),
file_type="3mf",
file_size=source_path.stat().st_size,
file_hash=None,
thumbnail_path=thumbnail_db_path,
file_metadata=None,
is_external=is_external,
)
db.add_all([printer, library_file])
await db.flush()
item = PrintQueueItem(
printer_id=printer.id,
library_file_id=library_file.id,
status="pending",
cleanup_library_after_dispatch=cleanup,
bed_levelling="on",
flow_cali="off",
vibration_cali=True,
layer_inspect=False,
timelapse=False,
use_ams=True,
nozzle_offset_cali="on",
)
db.add(item)
await db.commit()
return SimpleNamespace(
session_maker=session_maker,
base_dir=base_dir,
source_path=source_path,
thumbnail_path=thumbnail_actual_path,
printer_id=printer.id,
library_file_id=library_file.id,
queue_item_id=item.id,
archive_path=None,
upload=AsyncMock(return_value=True),
start_print=MagicMock(return_value=True),
)
try:
yield make_case
finally:
await engine.dispose()
async def _dispatch_library_item(ctx, *, archive_failure=False, unlink_side_effect=None):
scheduler = PrintScheduler()
async def archive_print(
self,
*,
printer_id,
source_file,
original_filename,
created_by_id=None,
project_id=None,
plate_id=None,
library_file_id=None,
):
if archive_failure:
raise RuntimeError("archive copy failed")
archive_rel_path = Path("archives") / f"archive-{ctx.queue_item_id}.3mf"
ctx.archive_path = ctx.base_dir / archive_rel_path
ctx.archive_path.parent.mkdir(parents=True, exist_ok=True)
ctx.archive_path.write_bytes(Path(source_file).read_bytes())
archive = PrintArchive(
printer_id=printer_id,
filename=original_filename,
file_path=str(archive_rel_path),
file_size=ctx.archive_path.stat().st_size,
content_hash=None,
thumbnail_path=None,
timelapse_path=None,
print_time_seconds=120,
status="completed",
project_id=project_id,
library_file_id=library_file_id,
created_by_id=created_by_id,
)
self.db.add(archive)
await self.db.flush()
return archive
patches = [
patch.object(scheduler_module.settings, "base_dir", ctx.base_dir),
patch("backend.app.services.archive.ArchiveService.archive_print", new=archive_print),
patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock(return_value=None)),
patch("backend.app.services.print_scheduler.printer_manager.start_print", ctx.start_print),
patch("backend.app.services.print_scheduler.printer_manager.set_awaiting_plate_clear", MagicMock()),
patch(
"backend.app.services.print_scheduler.get_ftp_retry_settings", AsyncMock(return_value=(False, 0, 0, 1.0))
),
patch("backend.app.services.print_scheduler.delete_file_async", AsyncMock(return_value=True)),
patch("backend.app.services.print_scheduler.upload_file_async", ctx.upload),
patch("backend.app.services.print_scheduler.cache_3mf_download", MagicMock()),
patch("backend.app.services.print_scheduler.spawn_background_task", MagicMock()),
patch("backend.app.services.notification_service.notification_service.on_queue_job_started", AsyncMock()),
patch("backend.app.services.notification_service.notification_service.on_queue_job_failed", AsyncMock()),
patch("backend.app.services.mqtt_relay.mqtt_relay.on_queue_job_started", AsyncMock()),
patch.object(scheduler, "_propagate_owner_to_printer_manager", AsyncMock()),
patch.object(scheduler, "_power_off_if_needed", AsyncMock()),
]
if unlink_side_effect:
patches.append(patch.object(type(ctx.source_path), "unlink", unlink_side_effect))
with ExitStack() as stack:
for patcher in patches:
stack.enter_context(patcher)
async with ctx.session_maker() as db:
item = await db.get(PrintQueueItem, ctx.queue_item_id)
await scheduler._start_print(db, item)
async def _queue_snapshot(ctx):
async with ctx.session_maker() as db:
item = await db.get(PrintQueueItem, ctx.queue_item_id)
library_file = await db.get(LibraryFile, ctx.library_file_id)
archive = await db.get(PrintArchive, item.archive_id) if item.archive_id else None
return item, library_file, archive
@pytest.mark.asyncio
async def test_cleanup_unlinks_library_file_and_removes_db_row(queue_factory):
ctx = await queue_factory(cleanup=True)
await _dispatch_library_item(ctx)
item, library_file, archive = await _queue_snapshot(ctx)
assert item.status == "printing"
assert item.library_file_id is None
assert item.archive_id == archive.id
assert library_file is None
assert not ctx.source_path.exists()
@pytest.mark.asyncio
async def test_external_library_file_skips_cleanup(queue_factory):
ctx = await queue_factory(cleanup=True, is_external=True)
await _dispatch_library_item(ctx)
item, library_file, archive = await _queue_snapshot(ctx)
assert item.status == "printing"
assert item.library_file_id == ctx.library_file_id
assert item.archive_id == archive.id
assert library_file is not None
assert ctx.source_path.exists()
@pytest.mark.asyncio
async def test_archive_creation_failure_skips_cleanup_and_dispatch(queue_factory):
ctx = await queue_factory(cleanup=True, thumbnail_path="relative")
await _dispatch_library_item(ctx, archive_failure=True)
item, library_file, archive = await _queue_snapshot(ctx)
assert item.status == "failed"
assert item.error_message == "Failed to create archive from library file"
assert item.archive_id is None
assert archive is None
assert library_file is not None
assert ctx.source_path.exists()
assert ctx.thumbnail_path.exists()
ctx.upload.assert_not_awaited()
ctx.start_print.assert_not_called()
@pytest.mark.parametrize("thumbnail_path", ["absolute", "relative"])
@pytest.mark.asyncio
async def test_cleanup_resolves_absolute_and_relative_thumbnail_paths(queue_factory, thumbnail_path):
ctx = await queue_factory(cleanup=True, thumbnail_path=thumbnail_path)
await _dispatch_library_item(ctx)
item, library_file, archive = await _queue_snapshot(ctx)
assert item.status == "printing"
assert item.archive_id == archive.id
assert library_file is None
assert not ctx.source_path.exists()
assert not ctx.thumbnail_path.exists()
@pytest.mark.asyncio
async def test_archive_copy_survives_library_cleanup(queue_factory):
ctx = await queue_factory(cleanup=True)
await _dispatch_library_item(ctx)
assert not ctx.source_path.exists()
assert ctx.archive_path.exists()
assert ctx.archive_path.read_bytes() == b"library source"
uploaded_path = ctx.upload.await_args.args[2]
assert uploaded_path == ctx.archive_path
@pytest.mark.asyncio
async def test_oserror_during_unlink_logs_orphan_path_and_does_not_crash_dispatch(queue_factory, caplog):
ctx = await queue_factory(cleanup=True, thumbnail_path="relative")
original_unlink = type(ctx.source_path).unlink
def unlink_with_source_failure(path, *args, **kwargs):
if Path(path) == ctx.source_path:
raise OSError("permission denied")
return original_unlink(path, *args, **kwargs)
with caplog.at_level("WARNING", logger="backend.app.services.print_scheduler"):
await _dispatch_library_item(ctx, unlink_side_effect=unlink_with_source_failure)
item, library_file, archive = await _queue_snapshot(ctx)
assert item.status == "printing"
assert item.archive_id == archive.id
assert item.library_file_id is None
assert library_file is None
assert ctx.source_path.exists()
assert not ctx.thumbnail_path.exists()
assert ctx.archive_path.exists()
assert "TRANSIENT_LIBRARY_FILE_ORPHAN" in caplog.text
assert str(ctx.source_path) in caplog.text
assert "permission denied" in caplog.text