Files
bambuddy/backend/tests/unit/services/test_background_dispatch.py
T
maziggy e4e6e9f8c4 Decouples file uploads and print start commands so that they run in the background asynchronously. When a print is started, the print modal no longer waits for the print to upload and start. Instead, a new toast-based UI appears with the status of prints being dispatched. Prints being dispatched can be cancelled during upload. If cancelled, the partially-uploaded gcode is deleted from the printer automatically before the FTP connection is closed.
This allows users sending particularly large prints to slow printers such as the P1-series to start a print and then move onto another task in Bambuddy immediately (such as starting more prints on more printers). It also gives the user visibility into what's happening instead of a loading indicator appearing for an indefinite period of time.

The new toast-based UI uses websockets to update in real time. It will also appear for other users / instances of Bambuddy, not just the user who started the prints, allowing more transparency and handling cases where the user closes the page and then comes back wanting to know the status of the dispatching.

fix: review fixes for background dispatch PR #408

- Restore missing imports in main.py (inventory, print_log, virtual_printers, mqtt_smart_plug_service)
- Guard voidresp() for A1 printers to prevent hang after upload
- Don't fail upload on voidresp() error since data transfer already completed
- Add ams_mapping to register_expected_print calls for Spoolman usage tracking
- Fix cancel_job TOCTOU race by using single lock acquisition
- Fix batch counter reset TOCTOU by re-checking condition inside second lock
- Add backgroundDispatch translations to fr.ts and pt-BR.ts
- Remove dead upload_progress_callback definitions
- Skip redundant "Print queued" toast in reprint mode (dispatch toast handles it)
- 5 backend tests: cancel_job single-lock TOCTOU, batch reset re-check,
  job lifecycle
- 2 FTP regression tests: voidresp error handling (upload-loop fix),
  A1 model voidresp skip
- 1 frontend test: reprint toast suppression
- CHANGELOG: background dispatch feature + test coverage entries
- README: add background dispatch to Scheduling & Automation
- Website: add feature item to Automation section
- Wiki: add Background Print Dispatch section to print-queue.md
2026-02-20 09:26:50 +01:00

323 lines
11 KiB
Python

"""Unit tests for background dispatch service."""
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
from backend.app.services.background_dispatch import (
ActiveDispatchState,
BackgroundDispatchService,
DispatchEnqueueRejected,
PrintDispatchJob,
)
@pytest.mark.asyncio
async def test_dispatch_rejects_when_printer_busy_printing():
"""Reject enqueue when target printer is already printing."""
service = BackgroundDispatchService()
with (
patch(
"backend.app.services.background_dispatch.printer_manager.get_status",
return_value=SimpleNamespace(state="RUNNING", gcode_file="active.gcode.3mf"),
),
pytest.raises(DispatchEnqueueRejected, match="currently busy printing"),
):
await service.dispatch_reprint_archive(
archive_id=1,
archive_name="Test Archive",
printer_id=10,
printer_name="Printer A",
options={},
requested_by_user_id=None,
requested_by_username=None,
)
@pytest.mark.asyncio
async def test_dispatch_enqueues_job_and_broadcasts_state():
"""Enqueue succeeds and emits websocket queue update."""
service = BackgroundDispatchService()
with (
patch("backend.app.services.background_dispatch.printer_manager.get_status", return_value=None),
patch(
"backend.app.services.background_dispatch.ws_manager.broadcast", new_callable=AsyncMock
) as mock_broadcast,
):
result = await service.dispatch_print_library_file(
file_id=22,
filename="cube.gcode.3mf",
printer_id=7,
printer_name="Printer B",
options={"plate_id": 2},
requested_by_user_id=5,
requested_by_username="tester",
)
assert result["status"] == "dispatched"
assert result["dispatch_job_id"] == 1
assert result["dispatch_position"] == 1
assert len(service._queued_jobs) == 1
mock_broadcast.assert_awaited_once()
payload = mock_broadcast.await_args.args[0]
assert payload["type"] == "background_dispatch"
assert payload["data"]["recent_event"]["status"] == "dispatched"
@pytest.mark.asyncio
async def test_cancel_queued_job_removes_it_and_broadcasts():
"""Cancelling queued job removes it immediately."""
service = BackgroundDispatchService()
with (
patch("backend.app.services.background_dispatch.printer_manager.get_status", return_value=None),
patch(
"backend.app.services.background_dispatch.ws_manager.broadcast", new_callable=AsyncMock
) as mock_broadcast,
):
result = await service.dispatch_reprint_archive(
archive_id=1,
archive_name="benchy.gcode.3mf",
printer_id=1,
printer_name="Printer 1",
options={},
requested_by_user_id=None,
requested_by_username=None,
)
mock_broadcast.reset_mock()
cancel_result = await service.cancel_job(result["dispatch_job_id"])
assert cancel_result["cancelled"] is True
assert cancel_result["pending"] is False
assert len(service._queued_jobs) == 0
assert service._batch_total == 0
mock_broadcast.assert_awaited_once()
payload = mock_broadcast.await_args.args[0]
assert payload["data"]["recent_event"]["status"] == "cancelled"
@pytest.mark.asyncio
async def test_cancel_active_job_marks_pending_and_sets_cancel_flag():
"""Cancelling active job marks it as pending cancellation."""
service = BackgroundDispatchService()
job = PrintDispatchJob(
id=42,
kind="reprint_archive",
source_id=100,
source_name="gearbox.gcode.3mf",
printer_id=3,
printer_name="Printer C",
)
service._active_jobs[job.id] = ActiveDispatchState(job=job, message="Uploading...")
with patch(
"backend.app.services.background_dispatch.ws_manager.broadcast", new_callable=AsyncMock
) as mock_broadcast:
result = await service.cancel_job(job.id)
assert result["cancelled"] is True
assert result["pending"] is True
assert job.id in service._cancel_requested_job_ids
mock_broadcast.assert_awaited_once()
payload = mock_broadcast.await_args.args[0]
assert payload["data"]["recent_event"]["status"] == "cancelling"
def test_resolve_plate_id_uses_request_value_when_provided(tmp_path):
"""Explicit plate_id wins over auto-detection."""
file_path = tmp_path / "dummy.3mf"
file_path.write_text("not-a-zip")
plate_id = BackgroundDispatchService._resolve_plate_id(file_path, requested_plate_id=9)
assert plate_id == 9
def test_resolve_plate_id_auto_detects_from_3mf(tmp_path):
"""Auto-detect plate from Metadata/plate_X.gcode entry."""
import zipfile
file_path = tmp_path / "multi.3mf"
with zipfile.ZipFile(file_path, "w") as zf:
zf.writestr("Metadata/plate_7.gcode", b"G1 X0 Y0")
plate_id = BackgroundDispatchService._resolve_plate_id(file_path, requested_plate_id=None)
assert plate_id == 7
def test_is_sliced_file_recognizes_supported_extensions():
"""Only .gcode and .gcode.3mf should be accepted."""
assert BackgroundDispatchService._is_sliced_file("part.gcode") is True
assert BackgroundDispatchService._is_sliced_file("part.gcode.3mf") is True
assert BackgroundDispatchService._is_sliced_file("part.3mf") is False
@pytest.mark.asyncio
async def test_cancel_job_not_found_returns_false():
"""Cancelling a nonexistent job returns not_found."""
service = BackgroundDispatchService()
with patch("backend.app.services.background_dispatch.ws_manager.broadcast", new_callable=AsyncMock):
result = await service.cancel_job(999)
assert result["cancelled"] is False
assert result["reason"] == "not_found"
@pytest.mark.asyncio
async def test_cancel_job_single_lock_covers_both_active_and_queued():
"""cancel_job checks both active and queued jobs under a single lock acquisition.
Regression test for TOCTOU race: previously two separate lock acquisitions allowed
the dispatcher loop to move a job from queue to active between them, causing cancel
to find it in neither place.
"""
service = BackgroundDispatchService()
# Set up a job in the queue AND an active job for a different printer
active_job = PrintDispatchJob(
id=1,
kind="reprint_archive",
source_id=10,
source_name="active.3mf",
printer_id=1,
printer_name="Printer 1",
)
service._active_jobs[active_job.id] = ActiveDispatchState(job=active_job, message="Uploading...")
queued_job = PrintDispatchJob(
id=2,
kind="reprint_archive",
source_id=20,
source_name="queued.3mf",
printer_id=2,
printer_name="Printer 2",
)
service._queued_jobs.append(queued_job)
service._batch_total = 2
with patch(
"backend.app.services.background_dispatch.ws_manager.broadcast", new_callable=AsyncMock
) as mock_broadcast:
# Cancel the queued job — should find it in single lock acquisition
result = await service.cancel_job(2)
assert result["cancelled"] is True
assert result["pending"] is False
assert len(service._queued_jobs) == 0
# Active job should be untouched
assert 1 in service._active_jobs
mock_broadcast.assert_awaited_once()
payload = mock_broadcast.await_args.args[0]
assert payload["data"]["recent_event"]["status"] == "cancelled"
@pytest.mark.asyncio
async def test_mark_job_finished_resets_batch_when_all_done():
"""Batch counters reset after last job completes."""
service = BackgroundDispatchService()
job = PrintDispatchJob(
id=1,
kind="reprint_archive",
source_id=10,
source_name="test.3mf",
printer_id=1,
printer_name="Printer 1",
)
service._active_jobs[job.id] = ActiveDispatchState(job=job, message="Done")
service._batch_total = 1
with patch("backend.app.services.background_dispatch.ws_manager.broadcast", new_callable=AsyncMock):
await service._mark_job_finished(job, failed=False, message="Complete")
assert service._batch_total == 0
assert service._batch_completed == 0
assert service._batch_failed == 0
@pytest.mark.asyncio
async def test_mark_job_finished_no_reset_when_jobs_remain():
"""Batch counters NOT reset when queued jobs remain."""
service = BackgroundDispatchService()
job = PrintDispatchJob(
id=1,
kind="reprint_archive",
source_id=10,
source_name="test.3mf",
printer_id=1,
printer_name="Printer 1",
)
remaining_job = PrintDispatchJob(
id=2,
kind="reprint_archive",
source_id=20,
source_name="next.3mf",
printer_id=2,
printer_name="Printer 2",
)
service._active_jobs[job.id] = ActiveDispatchState(job=job, message="Done")
service._queued_jobs.append(remaining_job)
service._batch_total = 2
with patch("backend.app.services.background_dispatch.ws_manager.broadcast", new_callable=AsyncMock):
await service._mark_job_finished(job, failed=False, message="Complete")
# Batch counters should NOT be reset — remaining job still queued
assert service._batch_total == 2
assert service._batch_completed == 1
@pytest.mark.asyncio
async def test_mark_job_finished_batch_reset_rechecks_under_lock():
"""Batch reset re-checks condition inside second lock acquisition.
Regression test for TOCTOU: a new dispatch between the two lock acquisitions
could get its counters zeroed if the re-check is missing.
"""
service = BackgroundDispatchService()
job = PrintDispatchJob(
id=1,
kind="reprint_archive",
source_id=10,
source_name="test.3mf",
printer_id=1,
printer_name="Printer 1",
)
service._active_jobs[job.id] = ActiveDispatchState(job=job, message="Done")
service._batch_total = 1
original_broadcast = AsyncMock()
async def inject_new_job_during_broadcast(msg):
"""Simulate a new dispatch arriving between the two lock acquisitions."""
await original_broadcast(msg)
# After broadcast (lock released), inject a new job before reset re-check
if not service._queued_jobs:
new_job = PrintDispatchJob(
id=99,
kind="reprint_archive",
source_id=99,
source_name="injected.3mf",
printer_id=5,
printer_name="Printer 5",
)
service._queued_jobs.append(new_job)
service._batch_total = 1
with patch(
"backend.app.services.background_dispatch.ws_manager.broadcast",
side_effect=inject_new_job_during_broadcast,
):
await service._mark_job_finished(job, failed=False, message="Complete")
# Re-check should prevent reset since a new job appeared
assert service._batch_total == 1
assert len(service._queued_jobs) == 1