Files
bambuddy/backend/tests/integration/test_background_dispatch_api.py
T

490 lines
21 KiB
Python

"""Integration tests for background dispatch API behavior."""
from unittest.mock import AsyncMock, patch
import pytest
from fastapi import HTTPException
from httpx import AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from backend.app.models.finance import BudgetReservation, CostCenter
from backend.app.models.settings import Settings
from backend.app.services.background_dispatch import BackgroundDispatchService, DispatchEnqueueRejected
async def enable_billing(db_session):
setting = await db_session.scalar(select(Settings).where(Settings.key == "billing_enabled"))
if setting is None:
db_session.add(Settings(key="billing_enabled", value="true"))
else:
setting.value = "true"
await db_session.commit()
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_forwards_cost_center_id(
self, async_client: AsyncClient, archive_factory, printer_factory, db_session, tmp_path
):
"""Reprint endpoint forwards cost_center_id into the dispatch options."""
await enable_billing(db_session)
printer = await printer_factory()
archive = await archive_factory(
printer.id,
filename="widget-cost.gcode.3mf",
file_path="archives/test/widget-cost.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")
cost_center = CostCenter(name="Reprint Cost Center", is_active=True, is_private=False, monthly_budget=10.0)
db_session.add(cost_center)
await db_session.commit()
await db_session.refresh(cost_center)
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": 16, "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, "cost_center_id": cost_center.id, "estimated_cost": 1.25},
)
assert response.status_code == 200
mock_dispatch.assert_awaited_once()
assert mock_dispatch.await_args.kwargs["options"]["cost_center_id"] == cost_center.id
assert mock_dispatch.await_args.kwargs["options"]["estimated_cost"] == 1.25
@pytest.mark.asyncio
@pytest.mark.integration
async def test_reprint_requires_cost_center_when_billing_enabled(
self, async_client: AsyncClient, archive_factory, printer_factory, db_session, tmp_path
):
await enable_billing(db_session)
printer = await printer_factory()
archive = await archive_factory(
printer.id,
filename="widget-missing-cost-center.gcode.3mf",
file_path="archives/test/widget-missing-cost-center.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": 17, "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, "estimated_cost": 1.25},
)
assert response.status_code == 400
assert "Cost center is required" in response.json()["detail"]
mock_dispatch.assert_not_awaited()
@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_forwards_cost_center_id(
self, async_client: AsyncClient, library_file_factory, printer_factory, db_session, tmp_path
):
"""Library print endpoint forwards cost_center_id into the dispatch options."""
await enable_billing(db_session)
printer = await printer_factory()
lib_file = await library_file_factory(filename="library-cost.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")
cost_center = CostCenter(name="Library Cost Center", is_active=True, is_private=False, monthly_budget=10.0)
db_session.add(cost_center)
await db_session.commit()
await db_session.refresh(cost_center)
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": 22, "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, "cost_center_id": cost_center.id, "estimated_cost": 1.5},
)
assert response.status_code == 200
mock_dispatch.assert_awaited_once()
assert mock_dispatch.await_args.kwargs["options"]["cost_center_id"] == cost_center.id
assert mock_dispatch.await_args.kwargs["options"]["estimated_cost"] == 1.5
@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"
class TestBackgroundDispatchBudgetReservations:
"""Tests for persisted budget reservations on background dispatch enqueue."""
@pytest.mark.asyncio
@pytest.mark.integration
async def test_background_dispatch_persists_budget_reservation_and_blocks_oversubscribe(
self, printer_factory, db_session, test_engine
):
await enable_billing(db_session)
printer_one = await printer_factory()
printer_two = await printer_factory()
cost_center = CostCenter(name="Dispatch Reservation CC", is_active=True, is_private=False, monthly_budget=2.0)
db_session.add(cost_center)
await db_session.commit()
await db_session.refresh(cost_center)
service = BackgroundDispatchService()
test_async_session = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
with (
patch("backend.app.services.background_dispatch.async_session", test_async_session),
patch("backend.app.services.printer_manager.printer_manager.get_status", return_value=None),
):
first = await service.dispatch_reprint_archive(
archive_id=100,
archive_name="first.gcode.3mf",
printer_id=printer_one.id,
printer_name=printer_one.name,
options={"cost_center_id": cost_center.id, "estimated_cost": 1.5},
requested_by_user_id=None,
requested_by_username=None,
)
with pytest.raises(HTTPException) as exc:
await service.dispatch_reprint_archive(
archive_id=101,
archive_name="second.gcode.3mf",
printer_id=printer_two.id,
printer_name=printer_two.name,
options={"cost_center_id": cost_center.id, "estimated_cost": 1.0},
requested_by_user_id=None,
requested_by_username=None,
)
assert exc.value.status_code == 400
assert "exceeds available cost center budget" in exc.value.detail
reservation = await db_session.scalar(
select(BudgetReservation).where(
BudgetReservation.source_type == "background_dispatch",
BudgetReservation.source_id == first["dispatch_job_id"],
)
)
assert reservation is not None
assert reservation.status == "active"
assert reservation.cost_center_id == cost_center.id
assert reservation.amount == 1.5
assert reservation.print_archive_id == 100