"""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