mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-22 03:49:18 +02:00
test(queue,billing,vp): pin dispatch failures, creator scope and busy-printer holds, aborted-print usage fallbacks, end-to-end print completion and billing, VP MQTT pre-login and rate-limit guards; test schema loads every model
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user