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