mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-21 10:15:04 +02:00
Every pipeline endpoint answered 403 for API keys whatever scopes the key carried. PR A parked all three permissions on the admin denylist until the run dispatch existed to decide about; it landed in PR C and the parking was never revisited. PIPELINES_READ now rides can_read_status. PIPELINES_RUN requires can_queue AND can_manage_library together, so the allowlist gained tuple values: a run slices into the library and then queues prints, and mapping it to either flag alone would hand that flag the other one's authority. The 403 names every flag the key is short of. PIPELINES_WRITE stays admin-only -- a key can run the recipe, not rewrite it or clear the log. Opening the run route also needed the cloud-owner fallback the direct slice route makes: a pipeline can carry Bambu/Orca Cloud presets, and resolving those reads a token off a user record that an API-keyed request does not have. retry_failed forwards the new dependency explicitly, since a direct call receives the Depends marker rather than None.
161 lines
6.6 KiB
Python
161 lines
6.6 KiB
Python
"""The scheduler defers (never fails) a dispatch that hits a busy printer (#2598).
|
|
|
|
check_queue gates dispatch on _is_printer_idle(), but that treats FINISH as
|
|
idle and a printer can keep reporting FINISH for tens of seconds after it
|
|
accepted a project_file; a watchdog revert (#2555) also releases the dispatch
|
|
hold. So a re-selected item can reach _start_print while its printer has
|
|
actually started printing. Two guards keep that from cancelling the live job:
|
|
|
|
* pre-dispatch — before the FTP upload, a busy printer defers (item stays
|
|
pending), so there is no wasted upload and no start command;
|
|
* post-dispatch — if the printer goes busy in the upload window and
|
|
start_print() returns False, the item is reverted to pending (deferred), not
|
|
marked failed.
|
|
"""
|
|
|
|
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.print_queue import PrintQueueItem
|
|
from backend.app.models.printer import Printer
|
|
from backend.app.services.print_scheduler import PrintScheduler
|
|
from backend.tests._fixtures.background_tasks import discarding_spawn_patch
|
|
|
|
|
|
@pytest.fixture
|
|
async def dispatch_case(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)
|
|
|
|
base_dir = tmp_path / "case"
|
|
base_dir.mkdir()
|
|
archive_rel = Path("archives") / "job.3mf"
|
|
archive_abs = base_dir / archive_rel
|
|
archive_abs.parent.mkdir(parents=True, exist_ok=True)
|
|
archive_abs.write_bytes(b"archive payload")
|
|
|
|
async with session_maker() as db:
|
|
printer = Printer(
|
|
name="Printer",
|
|
serial_number="SERIAL",
|
|
ip_address="127.0.0.1",
|
|
access_code="access-code",
|
|
model="A1MINI",
|
|
)
|
|
db.add(printer)
|
|
await db.flush()
|
|
archive = PrintArchive(
|
|
printer_id=printer.id,
|
|
filename="job.3mf",
|
|
file_path=str(archive_rel),
|
|
file_size=archive_abs.stat().st_size,
|
|
status="completed",
|
|
)
|
|
db.add(archive)
|
|
await db.flush()
|
|
item = PrintQueueItem(printer_id=printer.id, archive_id=archive.id, status="pending")
|
|
db.add(item)
|
|
await db.commit()
|
|
ids = SimpleNamespace(printer_id=printer.id, archive_id=archive.id, item_id=item.id)
|
|
|
|
try:
|
|
yield SimpleNamespace(session_maker=session_maker, base_dir=base_dir, ids=ids)
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
|
|
def _base_patches(scheduler, ctx, upload_mock, start_print_mock, get_status):
|
|
return [
|
|
patch.object(scheduler_module.settings, "base_dir", ctx.base_dir),
|
|
patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
|
|
patch("backend.app.services.print_scheduler.printer_manager.get_status", get_status),
|
|
patch("backend.app.services.print_scheduler.printer_manager.start_print", start_print_mock),
|
|
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", upload_mock),
|
|
patch("backend.app.services.print_scheduler.cache_3mf_download", MagicMock()),
|
|
discarding_spawn_patch(),
|
|
patch.object(scheduler, "_propagate_owner_to_printer_manager", AsyncMock()),
|
|
patch.object(scheduler, "_power_off_if_needed", AsyncMock()),
|
|
patch.object(scheduler, "_preheat_and_soak", AsyncMock()),
|
|
]
|
|
|
|
|
|
async def _final_item(ctx):
|
|
async with ctx.session_maker() as db:
|
|
return await db.get(PrintQueueItem, ctx.ids.item_id)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pre_dispatch_busy_defers_without_upload(dispatch_case):
|
|
"""Printer already RUNNING when _start_print begins → defer, no upload/start."""
|
|
scheduler = PrintScheduler()
|
|
upload = AsyncMock(return_value=True)
|
|
start_print = MagicMock(return_value=True)
|
|
get_status = MagicMock(return_value=SimpleNamespace(state="RUNNING", subtask_id=None, gcode_file=None))
|
|
|
|
async with dispatch_case.session_maker() as db:
|
|
item = await db.get(PrintQueueItem, dispatch_case.ids.item_id)
|
|
with ExitStack() as stack:
|
|
for p in _base_patches(scheduler, dispatch_case, upload, start_print, get_status):
|
|
stack.enter_context(p)
|
|
await scheduler._start_print(db, item)
|
|
|
|
upload.assert_not_awaited()
|
|
start_print.assert_not_called()
|
|
final = await _final_item(dispatch_case)
|
|
assert final.status == "pending", "a busy printer must defer the item, not consume it"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_post_dispatch_busy_reverts_to_pending_not_failed(dispatch_case):
|
|
"""Printer goes busy in the upload window; start_print returns False → defer."""
|
|
scheduler = PrintScheduler()
|
|
upload = AsyncMock(return_value=True)
|
|
|
|
holder = {"state": "IDLE"}
|
|
|
|
class _Status:
|
|
subtask_id = None
|
|
gcode_file = None
|
|
|
|
@property
|
|
def state(self):
|
|
return holder["state"]
|
|
|
|
def _start_print(*args, **kwargs):
|
|
# The printer became busy between the pre-dispatch check and the publish.
|
|
holder["state"] = "RUNNING"
|
|
return False # start_print() refused: busy
|
|
|
|
start_print = MagicMock(side_effect=_start_print)
|
|
get_status = MagicMock(return_value=_Status())
|
|
|
|
async with dispatch_case.session_maker() as db:
|
|
item = await db.get(PrintQueueItem, dispatch_case.ids.item_id)
|
|
with ExitStack() as stack:
|
|
for p in _base_patches(scheduler, dispatch_case, upload, start_print, get_status):
|
|
stack.enter_context(p)
|
|
await scheduler._start_print(db, item)
|
|
|
|
upload.assert_awaited_once() # it proceeded past the (idle) pre-dispatch check
|
|
start_print.assert_called_once()
|
|
final = await _final_item(dispatch_case)
|
|
assert final.status == "pending", "a busy-refused start must defer, not fail the item"
|
|
assert final.started_at is None
|