|
@@ -0,0 +1,159 @@
|
|
|
|
|
+"""A queued print's completion, end to end on the database.
|
|
|
|
|
+
|
|
|
|
|
+The archive row, the print log entry, the queue item and the wallet charge are
|
|
|
|
|
+what the rest of Bambuddy reads afterwards (archives page, statistics, project
|
|
|
|
|
+totals, the cost centre's budget). Each was only pinned through mocks of the
|
|
|
|
|
+function that writes it; here ``on_print_complete`` runs against real rows and
|
|
|
|
|
+only the outside world (websocket, MQTT relay, notifications, plugs, the
|
|
|
|
|
+printer's SD card) is mocked; the printer manager knows no printer.
|
|
|
|
|
+"""
|
|
|
|
|
+
|
|
|
|
|
+import asyncio
|
|
|
|
|
+from unittest.mock import AsyncMock, patch
|
|
|
|
|
+
|
|
|
|
|
+import pytest
|
|
|
|
|
+from sqlalchemy import select
|
|
|
|
|
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
|
|
|
+
|
|
|
|
|
+from backend.app import main as main_module
|
|
|
|
|
+from backend.app.models.archive import PrintArchive
|
|
|
|
|
+from backend.app.models.finance import CostCenter, WalletTransaction
|
|
|
|
|
+from backend.app.models.print_log import PrintLogEntry
|
|
|
|
|
+from backend.app.models.print_queue import PrintQueueItem
|
|
|
|
|
+from backend.app.models.settings import Settings
|
|
|
|
|
+from backend.app.models.user import User
|
|
|
|
|
+from backend.app.services.bambu_ftp import DeleteResult
|
|
|
|
|
+
|
|
|
|
|
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
|
|
|
|
|
+
|
|
|
|
|
+RUN = "run-end-to-end"
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@pytest.fixture(autouse=True)
|
|
|
|
|
+def _clean():
|
|
|
|
|
+ """The completion marks the printer as awaiting a plate clear on the real
|
|
|
|
|
+ printer manager; the next test on this worker must not inherit that."""
|
|
|
|
|
+ from backend.app.services.printer_manager import printer_manager
|
|
|
|
|
+
|
|
|
|
|
+ main_module._active_prints.clear()
|
|
|
|
|
+ yield
|
|
|
|
|
+ main_module._active_prints.clear()
|
|
|
|
|
+ printer_manager._awaiting_plate_clear.clear()
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@pytest.fixture
|
|
|
|
|
+async def queued_print(db_session, printer_factory):
|
|
|
|
|
+ printer = await printer_factory(name="P")
|
|
|
|
|
+ db_session.add(Settings(key="billing_enabled", value="true"))
|
|
|
|
|
+ db_session.add(Settings(key="capture_finish_photo", value="false"))
|
|
|
|
|
+ user = User(username="payer", password_hash="x", is_active=True)
|
|
|
|
|
+ center = CostCenter(name="Lab", is_active=True, is_private=False)
|
|
|
|
|
+ db_session.add_all([user, center])
|
|
|
|
|
+ await db_session.flush()
|
|
|
|
|
+ archive = PrintArchive(
|
|
|
|
|
+ printer_id=printer.id,
|
|
|
|
|
+ filename="part.3mf",
|
|
|
|
|
+ print_name="part",
|
|
|
|
|
+ file_path="archives/test/part.3mf",
|
|
|
|
|
+ file_size=1,
|
|
|
|
|
+ status="printing",
|
|
|
|
|
+ cost=5.0,
|
|
|
|
|
+ created_by_id=user.id,
|
|
|
|
|
+ billing_run_id=RUN,
|
|
|
|
|
+ )
|
|
|
|
|
+ db_session.add(archive)
|
|
|
|
|
+ await db_session.flush()
|
|
|
|
|
+ item = PrintQueueItem(
|
|
|
|
|
+ printer_id=printer.id,
|
|
|
|
|
+ archive_id=archive.id,
|
|
|
|
|
+ status="printing",
|
|
|
|
|
+ position=1,
|
|
|
|
|
+ created_by_id=user.id,
|
|
|
|
|
+ cost_center_id=center.id,
|
|
|
|
|
+ estimated_cost=5.0,
|
|
|
|
|
+ billing_run_id=RUN,
|
|
|
|
|
+ )
|
|
|
|
|
+ db_session.add(item)
|
|
|
|
|
+ await db_session.commit()
|
|
|
|
|
+ main_module._active_prints[(printer.id, "part.3mf")] = archive.id
|
|
|
|
|
+ return {"printer": printer.id, "user": user.id, "center": center.id, "archive": archive.id, "item": item.id}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def _complete(test_engine, printer_id: int, status: str) -> None:
|
|
|
|
|
+ maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
|
|
|
|
|
+ tasks_before = set(asyncio.all_tasks())
|
|
|
|
|
+ with (
|
|
|
|
|
+ patch("backend.app.main.async_session", maker),
|
|
|
|
|
+ patch("backend.app.core.database.async_session", maker),
|
|
|
|
|
+ patch("backend.app.main.ws_manager") as ws,
|
|
|
|
|
+ patch("backend.app.main.mqtt_relay") as relay,
|
|
|
|
|
+ patch("backend.app.main.notification_service") as notif,
|
|
|
|
|
+ patch("backend.app.main.smart_plug_manager") as plug,
|
|
|
|
|
+ patch("backend.app.services.spoolman_tracking.async_session", maker),
|
|
|
|
|
+ # The SD-card cleanup of the uploaded file
|
|
|
|
|
+ patch("backend.app.services.bambu_ftp.delete_file_async", AsyncMock(return_value=DeleteResult.DELETED)),
|
|
|
|
|
+ patch("backend.app.services.bambu_ftp.list_files_async", AsyncMock(return_value=[])),
|
|
|
|
|
+ ):
|
|
|
|
|
+ ws.send_print_complete = AsyncMock()
|
|
|
|
|
+ ws.send_archive_updated = AsyncMock()
|
|
|
|
|
+ ws.broadcast = AsyncMock()
|
|
|
|
|
+ relay.on_print_complete = AsyncMock()
|
|
|
|
|
+ notif.on_print_complete = AsyncMock()
|
|
|
|
|
+ plug.on_print_complete = AsyncMock()
|
|
|
|
|
+ await main_module.on_print_complete(
|
|
|
|
|
+ printer_id,
|
|
|
|
|
+ {"status": status, "filename": "part.gcode", "subtask_name": "part", "timelapse_was_active": False},
|
|
|
|
|
+ )
|
|
|
|
|
+ spawned = asyncio.all_tasks() - tasks_before
|
|
|
|
|
+ if spawned:
|
|
|
|
|
+ _done, pending = await asyncio.wait(spawned, timeout=10)
|
|
|
|
|
+ # Left running, they would carry on into the next test unpatched
|
|
|
|
|
+ assert not pending, f"background work still running: {pending}"
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def _rows(db_session, archive_id: int, item_id: int):
|
|
|
|
|
+ db_session.expire_all()
|
|
|
|
|
+ archive = await db_session.get(PrintArchive, archive_id)
|
|
|
|
|
+ item = await db_session.get(PrintQueueItem, item_id)
|
|
|
|
|
+ logs = (await db_session.scalars(select(PrintLogEntry).where(PrintLogEntry.archive_id == archive_id))).all()
|
|
|
|
|
+ charges = (
|
|
|
|
|
+ await db_session.scalars(select(WalletTransaction).where(WalletTransaction.transaction_type == "print_charge"))
|
|
|
|
|
+ ).all()
|
|
|
|
|
+ return archive, item, logs, charges
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def test_a_completed_print(test_engine, db_session, queued_print):
|
|
|
|
|
+ await _complete(test_engine, queued_print["printer"], "completed")
|
|
|
|
|
+
|
|
|
|
|
+ archive, item, logs, charges = await _rows(db_session, queued_print["archive"], queued_print["item"])
|
|
|
|
|
+ assert archive.status == "completed"
|
|
|
|
|
+ assert item.status == "completed"
|
|
|
|
|
+ assert [log.status for log in logs] == ["completed"]
|
|
|
|
|
+ assert len(charges) == 1
|
|
|
|
|
+ charge = charges[0]
|
|
|
|
|
+ assert (charge.user_id, charge.cost_center_id, charge.print_queue_id, charge.print_run_id) == (
|
|
|
|
|
+ queued_print["user"],
|
|
|
|
|
+ queued_print["center"],
|
|
|
|
|
+ queued_print["item"],
|
|
|
|
|
+ RUN,
|
|
|
|
|
+ )
|
|
|
|
|
+ assert abs(charge.amount) == pytest.approx(5.0)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def test_a_failed_print(test_engine, db_session, queued_print):
|
|
|
|
|
+ await _complete(test_engine, queued_print["printer"], "failed")
|
|
|
|
|
+
|
|
|
|
|
+ archive, item, logs, _charges = await _rows(db_session, queued_print["archive"], queued_print["item"])
|
|
|
|
|
+ assert archive.status == "failed"
|
|
|
|
|
+ assert item.status == "failed"
|
|
|
|
|
+ assert [log.status for log in logs] == ["failed"]
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def test_a_second_completion_charges_nothing_more(test_engine, db_session, queued_print):
|
|
|
|
|
+ """A duplicate completion callback (reconnect replay) is idempotent."""
|
|
|
|
|
+ await _complete(test_engine, queued_print["printer"], "completed")
|
|
|
|
|
+ main_module._active_prints[(queued_print["printer"], "part.3mf")] = queued_print["archive"]
|
|
|
|
|
+ await _complete(test_engine, queued_print["printer"], "completed")
|
|
|
|
|
+
|
|
|
|
|
+ _archive, _item, _logs, charges = await _rows(db_session, queued_print["archive"], queued_print["item"])
|
|
|
|
|
+ assert len(charges) == 1
|