Files
bambuddy/backend/tests/integration/test_background_dispatch_api.py
T
maziggy 1682b6956f fix(dispatch): clean up transient library upload from Direct-Print flow (#730)
The "Print" button on a printer card (and drag-drop-onto-card) used
  FileUploadModal to persist the file as a LibraryFile, then dispatched
  through POST /library/files/{id}/print. The LibraryFile row + disk file
  were left behind after every one-off print, polluting File Manager with
  entries the user never asked to save.

  FilePrintRequest.cleanup_library_after_dispatch (default False) opts
  into post-dispatch cleanup. When set, _run_print_library_file stages
  db.delete(lib_file) in the same transaction as archive_print so a
  mid-flight FTP / start_print failure rolls both back cleanly, commits
  together, then unlinks the library disk file + thumbnail after commit
  succeeds. External library files (is_external=True) are never touched.

  Only the Printers-page Direct-Print PrintModal sets the flag. Every
  other api.printLibraryFile caller (File Manager Print, Project Detail
  Print) leaves it unset — their entries are there by user intent.

  Also moves formatPrintName out of PrintersPage.tsx into a new
  utils/printName.ts module — fa1c46d9 (#881) exported it inline so its
  test could import it, tripping react-refresh/only-export-components.
2026-04-21 14:10:04 +02:00

307 lines
13 KiB
Python

"""Integration tests for background dispatch API behavior."""
from unittest.mock import AsyncMock, patch
import pytest
from httpx import AsyncClient
from backend.app.services.background_dispatch import DispatchEnqueueRejected
class TestBackgroundDispatchArchivesAPI:
"""Tests for archive reprint dispatch endpoint."""
@pytest.mark.asyncio
@pytest.mark.integration
async def test_reprint_returns_dispatched_payload(
self, async_client: AsyncClient, archive_factory, printer_factory, db_session, tmp_path
):
"""Reprint endpoint returns background dispatch metadata."""
printer = await printer_factory()
archive = await archive_factory(
printer.id,
filename="widget.gcode.3mf",
file_path="archives/test/widget.gcode.3mf",
)
archive_file = tmp_path / archive.file_path
archive_file.parent.mkdir(parents=True, exist_ok=True)
archive_file.write_bytes(b"3mf-data")
with (
patch("backend.app.api.routes.archives.settings.base_dir", tmp_path),
patch("backend.app.services.printer_manager.printer_manager.is_connected", return_value=True),
patch(
"backend.app.services.background_dispatch.background_dispatch.dispatch_reprint_archive",
new=AsyncMock(return_value={"dispatch_job_id": 15, "dispatch_position": 1}),
) as mock_dispatch,
):
response = await async_client.post(
f"/api/v1/archives/{archive.id}/reprint?printer_id={printer.id}",
json={"plate_id": 2},
)
assert response.status_code == 200
data = response.json()
assert data["status"] == "dispatched"
assert data["dispatch_job_id"] == 15
assert data["dispatch_position"] == 1
assert data["filename"] == "widget.gcode.3mf"
mock_dispatch.assert_awaited_once()
kwargs = mock_dispatch.await_args.kwargs
assert kwargs["archive_name"].endswith("• Plate 2")
assert kwargs["options"]["plate_id"] == 2
@pytest.mark.asyncio
@pytest.mark.integration
async def test_reprint_returns_409_when_enqueue_rejected(
self, async_client: AsyncClient, archive_factory, printer_factory, db_session, tmp_path
):
"""Reprint endpoint maps enqueue rejection to HTTP 409."""
printer = await printer_factory()
archive = await archive_factory(
printer.id,
filename="widget2.gcode.3mf",
file_path="archives/test/widget2.gcode.3mf",
)
archive_file = tmp_path / archive.file_path
archive_file.parent.mkdir(parents=True, exist_ok=True)
archive_file.write_bytes(b"3mf-data")
with (
patch("backend.app.api.routes.archives.settings.base_dir", tmp_path),
patch("backend.app.services.printer_manager.printer_manager.is_connected", return_value=True),
patch(
"backend.app.services.background_dispatch.background_dispatch.dispatch_reprint_archive",
new=AsyncMock(side_effect=DispatchEnqueueRejected("already has a background dispatch")),
),
):
response = await async_client.post(
f"/api/v1/archives/{archive.id}/reprint?printer_id={printer.id}",
json={"plate_id": 1},
)
assert response.status_code == 409
assert "already has a background dispatch" in response.json()["detail"]
class TestBackgroundDispatchLibraryAPI:
"""Tests for library print dispatch endpoint."""
@pytest.fixture
async def library_file_factory(self, db_session):
"""Factory to create library files."""
async def _create_file(**kwargs):
from backend.app.models.library import LibraryFile
defaults = {
"filename": "library_part.gcode.3mf",
"file_path": "library/files/library_part.gcode.3mf",
"file_type": "gcode",
"file_size": 1024,
}
defaults.update(kwargs)
lib_file = LibraryFile(**defaults)
db_session.add(lib_file)
await db_session.commit()
await db_session.refresh(lib_file)
return lib_file
return _create_file
@pytest.mark.asyncio
@pytest.mark.integration
async def test_library_print_returns_dispatched_payload(
self, async_client: AsyncClient, library_file_factory, printer_factory, db_session, tmp_path
):
"""Library print endpoint returns dispatch job metadata."""
printer = await printer_factory()
lib_file = await library_file_factory()
disk_path = tmp_path / lib_file.file_path
disk_path.parent.mkdir(parents=True, exist_ok=True)
disk_path.write_bytes(b"library data")
with (
patch("backend.app.api.routes.library.app_settings.base_dir", tmp_path),
patch("backend.app.services.printer_manager.printer_manager.is_connected", return_value=True),
patch(
"backend.app.services.background_dispatch.background_dispatch.dispatch_print_library_file",
new=AsyncMock(return_value={"dispatch_job_id": 21, "dispatch_position": 2}),
) as mock_dispatch,
):
response = await async_client.post(
f"/api/v1/library/files/{lib_file.id}/print?printer_id={printer.id}",
json={"plate_id": 4},
)
assert response.status_code == 200
data = response.json()
assert data["status"] == "dispatched"
assert data["dispatch_job_id"] == 21
assert data["dispatch_position"] == 2
assert data["archive_id"] is None
mock_dispatch.assert_awaited_once()
kwargs = mock_dispatch.await_args.kwargs
assert kwargs["filename"].endswith("• Plate 4")
assert kwargs["options"]["plate_id"] == 4
@pytest.mark.asyncio
@pytest.mark.integration
async def test_library_print_returns_409_when_enqueue_rejected(
self, async_client: AsyncClient, library_file_factory, printer_factory, db_session, tmp_path
):
"""Library print endpoint maps enqueue rejection to HTTP 409."""
printer = await printer_factory()
lib_file = await library_file_factory(filename="another_part.gcode")
disk_path = tmp_path / lib_file.file_path
disk_path.parent.mkdir(parents=True, exist_ok=True)
disk_path.write_bytes(b"library data")
with (
patch("backend.app.api.routes.library.app_settings.base_dir", tmp_path),
patch("backend.app.services.printer_manager.printer_manager.is_connected", return_value=True),
patch(
"backend.app.services.background_dispatch.background_dispatch.dispatch_print_library_file",
new=AsyncMock(side_effect=DispatchEnqueueRejected("queue conflict")),
),
):
response = await async_client.post(
f"/api/v1/library/files/{lib_file.id}/print?printer_id={printer.id}",
json={"plate_id": 1},
)
assert response.status_code == 409
assert "queue conflict" in response.json()["detail"]
@pytest.mark.asyncio
@pytest.mark.integration
async def test_library_print_cleanup_flag_defaults_false(
self, async_client: AsyncClient, library_file_factory, printer_factory, db_session, tmp_path
):
"""Absent cleanup_library_after_dispatch in the request body ⇒ False reaches the dispatcher.
Guards the File Manager / Project Detail paths from accidental deletion."""
printer = await printer_factory()
lib_file = await library_file_factory(filename="filemgr_part.gcode.3mf")
disk_path = tmp_path / lib_file.file_path
disk_path.parent.mkdir(parents=True, exist_ok=True)
disk_path.write_bytes(b"library data")
with (
patch("backend.app.api.routes.library.app_settings.base_dir", tmp_path),
patch("backend.app.services.printer_manager.printer_manager.is_connected", return_value=True),
patch(
"backend.app.services.background_dispatch.background_dispatch.dispatch_print_library_file",
new=AsyncMock(return_value={"dispatch_job_id": 30, "dispatch_position": 1}),
) as mock_dispatch,
):
response = await async_client.post(
f"/api/v1/library/files/{lib_file.id}/print?printer_id={printer.id}",
json={},
)
assert response.status_code == 200
mock_dispatch.assert_awaited_once()
assert mock_dispatch.await_args.kwargs["cleanup_library_after_dispatch"] is False
# cleanup flag must never leak into the print-option dict forwarded to MQTT
assert "cleanup_library_after_dispatch" not in mock_dispatch.await_args.kwargs["options"]
@pytest.mark.asyncio
@pytest.mark.integration
async def test_library_print_forwards_cleanup_flag_true(
self, async_client: AsyncClient, library_file_factory, printer_factory, db_session, tmp_path
):
"""Direct-Print flow sends cleanup_library_after_dispatch=True, which must reach the dispatcher."""
printer = await printer_factory()
lib_file = await library_file_factory(filename="transient_part.gcode.3mf")
disk_path = tmp_path / lib_file.file_path
disk_path.parent.mkdir(parents=True, exist_ok=True)
disk_path.write_bytes(b"library data")
with (
patch("backend.app.api.routes.library.app_settings.base_dir", tmp_path),
patch("backend.app.services.printer_manager.printer_manager.is_connected", return_value=True),
patch(
"backend.app.services.background_dispatch.background_dispatch.dispatch_print_library_file",
new=AsyncMock(return_value={"dispatch_job_id": 31, "dispatch_position": 1}),
) as mock_dispatch,
):
response = await async_client.post(
f"/api/v1/library/files/{lib_file.id}/print?printer_id={printer.id}",
json={"cleanup_library_after_dispatch": True},
)
assert response.status_code == 200
mock_dispatch.assert_awaited_once()
assert mock_dispatch.await_args.kwargs["cleanup_library_after_dispatch"] is True
class TestBackgroundDispatchCancelAPI:
"""Tests for /background-dispatch cancel endpoint."""
@pytest.mark.asyncio
@pytest.mark.integration
async def test_cancel_job_returns_cancelled(self, async_client: AsyncClient):
"""Cancel endpoint returns cancelled for queued job."""
with patch(
"backend.app.services.background_dispatch.background_dispatch.cancel_job",
new=AsyncMock(
return_value={
"cancelled": True,
"pending": False,
"job_id": 9,
"source_name": "cube.gcode.3mf",
"printer_id": 1,
"printer_name": "Printer A",
}
),
):
response = await async_client.delete("/api/v1/background-dispatch/9")
assert response.status_code == 200
data = response.json()
assert data["status"] == "cancelled"
assert data["job_id"] == 9
@pytest.mark.asyncio
@pytest.mark.integration
async def test_cancel_job_returns_cancelling_for_active_job(self, async_client: AsyncClient):
"""Cancel endpoint returns cancelling while active upload is being interrupted."""
with patch(
"backend.app.services.background_dispatch.background_dispatch.cancel_job",
new=AsyncMock(
return_value={
"cancelled": True,
"pending": True,
"job_id": 10,
"source_name": "cube.gcode.3mf",
"printer_id": 1,
"printer_name": "Printer A",
}
),
):
response = await async_client.delete("/api/v1/background-dispatch/10")
assert response.status_code == 200
assert response.json()["status"] == "cancelling"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_cancel_job_returns_404_when_not_found(self, async_client: AsyncClient):
"""Cancel endpoint returns 404 for unknown job id."""
with patch(
"backend.app.services.background_dispatch.background_dispatch.cancel_job",
new=AsyncMock(return_value={"cancelled": False, "reason": "not_found"}),
):
response = await async_client.delete("/api/v1/background-dispatch/999")
assert response.status_code == 404
assert response.json()["detail"] == "Dispatch job not found"