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:
@@ -446,49 +446,11 @@ async def test_engine():
|
||||
"""Create a test database engine."""
|
||||
engine = create_async_engine(TEST_DATABASE_URL, echo=False)
|
||||
|
||||
# Import all models to register them
|
||||
from backend.app.models import (
|
||||
active_print_session, # noqa: F401
|
||||
ams_history,
|
||||
ams_label,
|
||||
api_key,
|
||||
archive,
|
||||
auth_ephemeral,
|
||||
color_catalog,
|
||||
external_link,
|
||||
filament,
|
||||
group,
|
||||
kprofile_note,
|
||||
maintenance,
|
||||
notification,
|
||||
notification_template,
|
||||
oidc_provider,
|
||||
print_log,
|
||||
print_queue,
|
||||
printer,
|
||||
project,
|
||||
project_bom,
|
||||
scheduled_drying,
|
||||
settings,
|
||||
slot_preset,
|
||||
smart_plug,
|
||||
smart_plug_energy_snapshot, # noqa: F401
|
||||
sponsor_toast_state, # noqa: F401
|
||||
spool,
|
||||
spool_assignment,
|
||||
spool_catalog,
|
||||
spool_filament_preset,
|
||||
spool_k_profile,
|
||||
spool_usage_history,
|
||||
spoolbuddy_device,
|
||||
spoolman_k_profile,
|
||||
spoolman_slot_assignment,
|
||||
user,
|
||||
user_email_pref,
|
||||
user_otp_code,
|
||||
user_totp,
|
||||
virtual_printer,
|
||||
)
|
||||
# Every model, as the app's schema has them: a hand-kept list here fell
|
||||
# behind and left four tables out of the SQLite test database.
|
||||
from backend.app.core.database import load_all_models
|
||||
|
||||
load_all_models()
|
||||
|
||||
if TEST_PG_URL is not None:
|
||||
# The same engine setup as the app's (UTC session, aware datetimes
|
||||
|
||||
@@ -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
|
||||
@@ -443,6 +443,65 @@ class TestCostCalculation:
|
||||
# Cost = 20g / 1000 * 20.0 = 0.40
|
||||
assert results[0]["cost"] == 0.40
|
||||
|
||||
async def _failed_after_firmware_reset(self, *, last_progress=0.0, last_layer_num=0, layer_usage=None):
|
||||
"""A failed print whose printer already reset progress and layer to 0."""
|
||||
spool = _make_spool(spool_id=1, label_weight=1000, cost_per_kg=20.0)
|
||||
_active_sessions[1] = PrintSession(
|
||||
printer_id=1,
|
||||
print_name="Test",
|
||||
started_at=datetime.now(timezone.utc),
|
||||
tray_remain_start={(0, 0): 80},
|
||||
tray_now_at_start=0,
|
||||
)
|
||||
printer_manager = MagicMock()
|
||||
printer_manager.get_status.return_value = SimpleNamespace(
|
||||
raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]}, progress=0, layer_num=0, tray_now=0
|
||||
)
|
||||
db = _mock_db_sequential([_make_archive(archive_id=10), None, _make_assignment(spool_id=1), spool])
|
||||
cumulative = MagicMock(return_value={0: 1000.0}) # filament 0 = slot 1
|
||||
with (
|
||||
patch("backend.app.core.config.settings") as mock_settings,
|
||||
patch("backend.app.api.routes.settings.get_setting", return_value="15.0"),
|
||||
patch(
|
||||
"backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
|
||||
return_value=[{"slot_id": 1, "used_g": 40.0, "type": "PLA", "color": "#FF0000"}],
|
||||
),
|
||||
patch("backend.app.utils.threemf_tools.extract_layer_filament_usage_from_3mf", return_value=layer_usage),
|
||||
patch("backend.app.utils.threemf_tools.get_cumulative_usage_at_layer", cumulative),
|
||||
patch("backend.app.utils.threemf_tools.extract_filament_properties_from_3mf", return_value={}),
|
||||
patch("backend.app.utils.threemf_tools.mm_to_grams", return_value=7.0),
|
||||
):
|
||||
mock_settings.base_dir = MagicMock()
|
||||
mock_path = MagicMock()
|
||||
mock_path.exists.return_value = True
|
||||
mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
|
||||
results = await on_print_complete(
|
||||
printer_id=1,
|
||||
data={"status": "failed", "last_progress": last_progress, "last_layer_num": last_layer_num},
|
||||
printer_manager=printer_manager,
|
||||
db=db,
|
||||
archive_id=10,
|
||||
)
|
||||
return results, cumulative
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_progress_reset_to_zero_falls_back_to_the_last_progress(self):
|
||||
"""Firmware zeroes progress on cancel; 0 % would charge nothing."""
|
||||
results, _ = await self._failed_after_firmware_reset(last_progress=40.0)
|
||||
|
||||
# 40 % of 40 g
|
||||
assert results[0]["weight_used"] == 16.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_layer_reset_to_zero_falls_back_to_the_last_layer(self):
|
||||
results, cumulative = await self._failed_after_firmware_reset(
|
||||
last_progress=40.0, last_layer_num=12, layer_usage={1: {12: 1000.0}}
|
||||
)
|
||||
|
||||
assert cumulative.call_args.args[1] == 12
|
||||
# The per-layer grams, not the 40 % estimate
|
||||
assert results[0]["weight_used"] == 7.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cost_with_ams_fallback_tracking(self):
|
||||
"""AMS fallback tracking also calculates cost correctly."""
|
||||
|
||||
@@ -211,138 +211,5 @@ class TestMultiplePrintingQueueItemsWarning:
|
||||
assert "printer 7" in bug_warnings[0].message
|
||||
|
||||
|
||||
class TestBusyPrinterSeedingFromPrintingItems:
|
||||
"""Regression for the duplicate-dispatch bug observed with quantity>1 batches.
|
||||
|
||||
The old scheduler seeded ``busy_printers`` with an empty set and relied on
|
||||
``_is_printer_idle()`` to gate dispatch. On H2D / P1 series the MQTT state
|
||||
lags several seconds behind the print command, so the next ``check_queue``
|
||||
tick saw IDLE and dispatched a second queue item onto the same printer —
|
||||
both items ended up in 'printing' status. The fix seeds ``busy_printers``
|
||||
up-front with every printer that already has an item in 'printing' status.
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_seed_query_returns_printers_with_printing_items(self):
|
||||
"""The seeding query must return every printer_id that has a 'printing' item."""
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
import backend.app.models # noqa: F401
|
||||
from backend.app.core.database import Base
|
||||
from backend.app.models.print_queue import PrintQueueItem
|
||||
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
async with session_maker() as db:
|
||||
db.add_all(
|
||||
[
|
||||
PrintQueueItem(printer_id=1, status="printing", position=1, archive_id=10),
|
||||
PrintQueueItem(printer_id=1, status="pending", position=2, archive_id=10),
|
||||
PrintQueueItem(printer_id=2, status="printing", position=1, archive_id=11),
|
||||
PrintQueueItem(printer_id=3, status="pending", position=1, archive_id=12),
|
||||
PrintQueueItem(printer_id=None, status="pending", position=1, archive_id=13),
|
||||
]
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
result = await db.execute(
|
||||
select(PrintQueueItem.printer_id)
|
||||
.where(PrintQueueItem.status == "printing")
|
||||
.where(PrintQueueItem.printer_id.is_not(None))
|
||||
)
|
||||
busy_printers = {pid for (pid,) in result.all() if pid is not None}
|
||||
|
||||
assert busy_printers == {1, 2}
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_seed_query_empty_when_no_printing_items(self):
|
||||
"""With only pending items, no printer is considered busy by the query."""
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
import backend.app.models # noqa: F401
|
||||
from backend.app.core.database import Base
|
||||
from backend.app.models.print_queue import PrintQueueItem
|
||||
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
async with session_maker() as db:
|
||||
db.add_all(
|
||||
[
|
||||
PrintQueueItem(printer_id=1, status="pending", position=1, archive_id=10),
|
||||
PrintQueueItem(printer_id=2, status="completed", position=1, archive_id=11),
|
||||
PrintQueueItem(printer_id=3, status="failed", position=1, archive_id=12),
|
||||
PrintQueueItem(printer_id=4, status="cancelled", position=1, archive_id=13),
|
||||
]
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
result = await db.execute(
|
||||
select(PrintQueueItem.printer_id)
|
||||
.where(PrintQueueItem.status == "printing")
|
||||
.where(PrintQueueItem.printer_id.is_not(None))
|
||||
)
|
||||
busy_printers = {pid for (pid,) in result.all() if pid is not None}
|
||||
|
||||
assert busy_printers == set()
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_queue_skips_printer_with_existing_printing_item(self, caplog):
|
||||
"""Simulate the exact observed bug: a pending item targets a printer that already
|
||||
has another queue item in 'printing' status. The scheduler must NOT dispatch the
|
||||
pending item even if the live MQTT state reports IDLE.
|
||||
"""
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
import backend.app.models # noqa: F401
|
||||
from backend.app.core.database import Base
|
||||
from backend.app.models.print_queue import PrintQueueItem
|
||||
from backend.app.services.print_scheduler import PrintScheduler
|
||||
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
async with session_maker() as db:
|
||||
db.add_all(
|
||||
[
|
||||
PrintQueueItem(printer_id=1, status="printing", position=1, archive_id=84),
|
||||
PrintQueueItem(printer_id=1, status="pending", position=2, archive_id=84),
|
||||
]
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
scheduler = PrintScheduler()
|
||||
start_print_mock = AsyncMock()
|
||||
|
||||
with (
|
||||
patch("backend.app.services.print_scheduler.async_session", session_maker),
|
||||
patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=False)),
|
||||
patch.object(scheduler, "_is_printer_idle", return_value=True),
|
||||
patch.object(scheduler, "_check_auto_drying", AsyncMock()),
|
||||
patch.object(scheduler, "_start_print", start_print_mock),
|
||||
patch("backend.app.services.print_scheduler.printer_manager") as mock_pm,
|
||||
):
|
||||
mock_pm.is_connected.return_value = True
|
||||
await scheduler.check_queue()
|
||||
|
||||
start_print_mock.assert_not_called()
|
||||
|
||||
async with session_maker() as db:
|
||||
rows = (await db.execute(select(PrintQueueItem).order_by(PrintQueueItem.position))).scalars().all()
|
||||
statuses = [r.status for r in rows]
|
||||
assert statuses == ["printing", "pending"]
|
||||
|
||||
await engine.dispose()
|
||||
# The busy-printer seed (an item already printing blocks a second dispatch) is
|
||||
# pinned on the real check_queue in test_scheduler_check_queue_holds.py.
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""What check_queue holds back, run on a real database with the dispatch drained.
|
||||
|
||||
* An item whose creator may no longer use its printer (#1727) is held with a
|
||||
reason, not dispatched and not failed.
|
||||
* A printer that already has an item printing takes no second one, even while
|
||||
it reports IDLE (the phantom-print seed).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import insert, update
|
||||
|
||||
from backend.app.models.group import Group, group_printers
|
||||
from backend.app.models.print_queue import PrintQueueItem
|
||||
from backend.app.models.printer import Printer
|
||||
from backend.app.models.settings import Settings
|
||||
from backend.app.models.user import User
|
||||
from backend.tests.unit.test_scheduler_busy_reasons_3018 import _scheduler, one_printer # noqa: F401
|
||||
|
||||
|
||||
async def _restricted_creator(session_maker, allowed_printer_id: int) -> int:
|
||||
async with session_maker() as db:
|
||||
db.add(Settings(key="auth_enabled", value="true"))
|
||||
group = Group(name="team", permissions=["queue:create"], restrict_printers=True)
|
||||
user = User(username="member", password_hash="x", is_active=True, groups=[group])
|
||||
db.add_all([group, user])
|
||||
await db.flush()
|
||||
await db.execute(insert(group_printers).values(group_id=group.id, printer_id=allowed_printer_id))
|
||||
await db.commit()
|
||||
return user.id
|
||||
|
||||
|
||||
async def _other_printer(session_maker) -> int:
|
||||
async with session_maker() as db:
|
||||
printer = Printer(name="Other", serial_number="OTHER", ip_address="10.0.0.2", access_code="code", model="X1C")
|
||||
db.add(printer)
|
||||
await db.commit()
|
||||
return printer.id
|
||||
|
||||
|
||||
class TestACreatorWhoLostThePrinter:
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_item_is_held_with_a_reason(self, one_printer): # noqa: F811
|
||||
allowed = await _other_printer(one_printer.session_maker)
|
||||
creator = await _restricted_creator(one_printer.session_maker, allowed)
|
||||
item_id = await one_printer.add_item()
|
||||
async with one_printer.session_maker() as db:
|
||||
await db.execute(update(PrintQueueItem).where(PrintQueueItem.id == item_id).values(created_by_id=creator))
|
||||
await db.commit()
|
||||
|
||||
async with _scheduler(one_printer, idle=True) as scheduler:
|
||||
await scheduler.check_queue()
|
||||
assert not scheduler._inflight
|
||||
|
||||
async with one_printer.session_maker() as db:
|
||||
item = await db.get(PrintQueueItem, item_id)
|
||||
assert item.status == "pending"
|
||||
assert "no longer has access" in (item.waiting_reason or "")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_creator_who_still_has_it_is_dispatched(self, one_printer): # noqa: F811
|
||||
creator = await _restricted_creator(one_printer.session_maker, one_printer.printer_id)
|
||||
item_id = await one_printer.add_item()
|
||||
async with one_printer.session_maker() as db:
|
||||
await db.execute(update(PrintQueueItem).where(PrintQueueItem.id == item_id).values(created_by_id=creator))
|
||||
await db.commit()
|
||||
|
||||
async with _scheduler(one_printer, idle=True) as scheduler:
|
||||
await scheduler.check_queue()
|
||||
|
||||
async with one_printer.session_maker() as db:
|
||||
assert (await db.get(PrintQueueItem, item_id)).status != "pending"
|
||||
|
||||
|
||||
class TestAPrinterWithAnItemPrinting:
|
||||
@pytest.mark.asyncio
|
||||
async def test_takes_no_second_item_while_it_reports_idle(self, one_printer): # noqa: F811
|
||||
printing_id = await one_printer.add_item(status="printing", position=0)
|
||||
pending_id = await one_printer.add_item(position=1)
|
||||
|
||||
async with _scheduler(one_printer, idle=True) as scheduler:
|
||||
await scheduler.check_queue()
|
||||
assert not scheduler._inflight
|
||||
|
||||
async with one_printer.session_maker() as db:
|
||||
assert (await db.get(PrintQueueItem, printing_id)).status == "printing"
|
||||
assert (await db.get(PrintQueueItem, pending_id)).status == "pending"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_without_it_the_same_item_goes_out(self, one_printer): # noqa: F811
|
||||
"""The control: nothing else holds this item back."""
|
||||
pending_id = await one_printer.add_item(position=1)
|
||||
|
||||
async with _scheduler(one_printer, idle=True) as scheduler:
|
||||
await scheduler.check_queue()
|
||||
|
||||
async with one_printer.session_maker() as db:
|
||||
assert (await db.get(PrintQueueItem, pending_id)).status != "pending"
|
||||
@@ -0,0 +1,107 @@
|
||||
"""How a dispatch that cannot go ahead ends.
|
||||
|
||||
Before the upload: an exhausted cost-centre budget, a printer row that is gone
|
||||
and a disconnected printer each fail the item with their own message, upload
|
||||
nothing and power the printer off if it was powered on for the job. After the
|
||||
upload: a start command the printer refused while it stayed idle fails the
|
||||
item, deletes the uploaded file again and sends the failure notification. A
|
||||
refusal because the printer turned busy defers instead (#2598, see
|
||||
test_scheduler_busy_defer_2598).
|
||||
"""
|
||||
|
||||
from contextlib import ExitStack
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from backend.app.models.print_queue import PrintQueueItem
|
||||
from backend.app.services.print_scheduler import PrintScheduler
|
||||
from backend.tests.unit.test_scheduler_busy_defer_2598 import _base_patches, _final_item, dispatch_case # noqa: F401
|
||||
|
||||
IDLE = SimpleNamespace(state="IDLE", subtask_id=None, gcode_file=None)
|
||||
|
||||
|
||||
async def _dispatch(ctx, *, start_print=None, extra=(), before=None):
|
||||
scheduler = PrintScheduler()
|
||||
upload = AsyncMock(return_value=True)
|
||||
start = start_print or MagicMock(return_value=True)
|
||||
failed = AsyncMock()
|
||||
async with ctx.session_maker() as db:
|
||||
item = await db.get(PrintQueueItem, ctx.ids.item_id)
|
||||
if before is not None:
|
||||
await before(db, item)
|
||||
with ExitStack() as stack:
|
||||
for p in _base_patches(scheduler, ctx, upload, start, MagicMock(return_value=IDLE)):
|
||||
stack.enter_context(p)
|
||||
for p in extra:
|
||||
stack.enter_context(p)
|
||||
stack.enter_context(
|
||||
patch(
|
||||
"backend.app.services.notification_service.notification_service.on_queue_job_failed",
|
||||
failed,
|
||||
)
|
||||
)
|
||||
delete = stack.enter_context(
|
||||
patch("backend.app.services.print_scheduler.delete_file_async", AsyncMock(return_value=True))
|
||||
)
|
||||
power_off = scheduler._power_off_if_needed
|
||||
await scheduler._start_print(db, item)
|
||||
return SimpleNamespace(
|
||||
power_off=power_off, upload=upload, start=start, delete=delete, failed=failed, item=await _final_item(ctx)
|
||||
)
|
||||
|
||||
|
||||
class TestBeforeTheUpload:
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_exhausted_budget(self, dispatch_case): # noqa: F811
|
||||
refused = AsyncMock(side_effect=HTTPException(400, "Cost center budget exceeded"))
|
||||
|
||||
result = await _dispatch(
|
||||
dispatch_case, extra=[patch("backend.app.services.print_scheduler.validate_print_budget", refused)]
|
||||
)
|
||||
|
||||
assert (result.item.status, result.item.error_message) == ("failed", "Cost center budget exceeded")
|
||||
result.upload.assert_not_awaited()
|
||||
result.start.assert_not_called()
|
||||
result.power_off.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_printer_that_is_gone(self, dispatch_case): # noqa: F811
|
||||
async def gone(db, item):
|
||||
item.printer_id = 9999
|
||||
await db.commit()
|
||||
|
||||
result = await _dispatch(dispatch_case, before=gone)
|
||||
|
||||
assert (result.item.status, result.item.error_message) == ("failed", "Printer not found")
|
||||
result.upload.assert_not_awaited()
|
||||
result.power_off.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_disconnected_printer(self, dispatch_case): # noqa: F811
|
||||
offline = patch(
|
||||
"backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=False)
|
||||
)
|
||||
|
||||
result = await _dispatch(dispatch_case, extra=[offline])
|
||||
|
||||
assert (result.item.status, result.item.error_message) == ("failed", "Printer not connected")
|
||||
result.upload.assert_not_awaited()
|
||||
result.start.assert_not_called()
|
||||
result.power_off.assert_awaited_once()
|
||||
|
||||
|
||||
class TestARefusedStartOnAnIdlePrinter:
|
||||
@pytest.mark.asyncio
|
||||
async def test_fails_the_item_and_removes_the_upload(self, dispatch_case): # noqa: F811
|
||||
result = await _dispatch(dispatch_case, start_print=MagicMock(return_value=False))
|
||||
|
||||
assert (result.item.status, result.item.error_message) == ("failed", "Failed to send print command to printer")
|
||||
result.upload.assert_awaited_once()
|
||||
uploaded_to = result.upload.await_args.args[3]
|
||||
# One clears the way before the upload, one removes the upload again
|
||||
assert [c.args[2] for c in result.delete.await_args_list] == [uploaded_to, uploaded_to]
|
||||
result.failed.assert_awaited_once()
|
||||
assert result.failed.await_args.kwargs["printer_id"] == dispatch_case.ids.printer_id
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Spoolman partial usage of an aborted print whose printer reset its layer to 0.
|
||||
|
||||
The firmware zeroes ``layer_num`` on cancel. The layer captured during the
|
||||
print then decides how far the print got; without it the abort reports no
|
||||
usage at all (the progress estimate needs a total layer count, which an
|
||||
aborted print may not have either).
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.app.services import spoolman_tracking
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(("last_layer_num", "charged_at"), [(12, 12), (None, None)])
|
||||
async def test_the_captured_layer_stands_in_for_the_reset_one(last_layer_num, charged_at):
|
||||
session = MagicMock()
|
||||
session.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
|
||||
session.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
tracking = SimpleNamespace(
|
||||
layer_usage={"12": {"0": 500.0}},
|
||||
filament_properties={},
|
||||
filament_usage=[{"slot_id": 1, "used_g": 10.0}],
|
||||
ams_trays={},
|
||||
slot_to_tray=[0],
|
||||
tray_remain_start={},
|
||||
archive_id=1,
|
||||
tray_now_at_start=0,
|
||||
)
|
||||
cumulative = MagicMock(return_value={})
|
||||
status = SimpleNamespace(layer_num=0, total_layers=None, raw_data={}, tray_change_log=[])
|
||||
|
||||
with (
|
||||
patch.object(spoolman_tracking, "async_session", session),
|
||||
patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value="true")),
|
||||
patch("backend.app.services.printer_manager.printer_manager.get_status", MagicMock(return_value=status)),
|
||||
patch.object(spoolman_tracking, "_get_printer_serial", AsyncMock(return_value="SERIAL")),
|
||||
patch.object(spoolman_tracking, "_get_spoolman_client_with_fallback", AsyncMock(return_value=MagicMock())),
|
||||
patch("backend.app.utils.threemf_tools.get_cumulative_usage_at_layer", cumulative),
|
||||
):
|
||||
await spoolman_tracking._report_partial_usage(1, tracking, last_layer_num=last_layer_num)
|
||||
|
||||
if charged_at is None:
|
||||
cumulative.assert_not_called()
|
||||
else:
|
||||
assert cumulative.call_args.args[1] == charged_at
|
||||
@@ -5,7 +5,7 @@ import asyncio
|
||||
import inspect
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -761,3 +761,85 @@ class TestSlicerCommandsTheLogKeepsWhole:
|
||||
)
|
||||
|
||||
assert '"tray_id": 26' not in text
|
||||
|
||||
|
||||
def _client_writer(peer: str = "1.2.3.4") -> MagicMock:
|
||||
writer = MagicMock()
|
||||
writer.write = MagicMock()
|
||||
writer.drain = AsyncMock()
|
||||
writer.close = MagicMock()
|
||||
writer.wait_closed = AsyncMock()
|
||||
writer.get_extra_info = MagicMock(side_effect=lambda name: (peer, 12345) if name == "peername" else None)
|
||||
return writer
|
||||
|
||||
|
||||
class TestNothingBeforeConnect:
|
||||
"""PUBLISH and SUBSCRIBE before a successful CONNECT reach nothing (V-U4)."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("packet", "handler"),
|
||||
[
|
||||
(bytes([0x30, 0x05, 0x00, 0x01, ord("t"), ord("{"), ord("}")]), "_handle_publish"),
|
||||
(bytes([0x82, 0x06, 0x00, 0x01, 0x00, 0x01, ord("t"), 0x00]), "_handle_subscribe"),
|
||||
],
|
||||
)
|
||||
async def test_a_packet_before_connect_is_dropped(self, packet, handler):
|
||||
server = _make_server()
|
||||
server._running = True
|
||||
reader = asyncio.StreamReader()
|
||||
connect = _build_connect_payload(keep_alive=60)
|
||||
# The packet first, then a valid CONNECT and the same packet again:
|
||||
# only the one after the CONNECT may reach the handler.
|
||||
reader.feed_data(packet + bytes([0x10, len(connect)]) + connect + packet + bytes([0xE0, 0x00]))
|
||||
reader.feed_eof()
|
||||
server._send_status_report = AsyncMock()
|
||||
handled = AsyncMock()
|
||||
|
||||
with patch.object(server, handler, handled):
|
||||
await server._handle_client(reader, _client_writer())
|
||||
|
||||
assert handled.await_count == 1
|
||||
|
||||
|
||||
class TestARateLimitedAddress:
|
||||
"""The rate limit refuses the right access code too, through the real handler (V-W2)."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_correct_connect_is_refused(self):
|
||||
from backend.app.services.virtual_printer.mqtt_server import _AUTH_RATE_LIMIT_MAX_ATTEMPTS
|
||||
|
||||
server = _make_server()
|
||||
server._running = True
|
||||
for _ in range(_AUTH_RATE_LIMIT_MAX_ATTEMPTS):
|
||||
server._record_auth_failure("1.2.3.4")
|
||||
reader = asyncio.StreamReader()
|
||||
connect = _build_connect_payload(keep_alive=60)
|
||||
reader.feed_data(bytes([0x10, len(connect)]) + connect)
|
||||
reader.feed_eof()
|
||||
writer = _client_writer()
|
||||
server._handle_connect = AsyncMock(return_value=(True, 60))
|
||||
|
||||
await server._handle_client(reader, writer)
|
||||
|
||||
server._handle_connect.assert_not_awaited()
|
||||
writer.write.assert_called_once_with(bytes([0x20, 0x02, 0x00, 0x05]))
|
||||
assert not server._clients
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_another_address_still_connects(self):
|
||||
from backend.app.services.virtual_printer.mqtt_server import _AUTH_RATE_LIMIT_MAX_ATTEMPTS
|
||||
|
||||
server = _make_server()
|
||||
server._running = True
|
||||
for _ in range(_AUTH_RATE_LIMIT_MAX_ATTEMPTS):
|
||||
server._record_auth_failure("1.2.3.4")
|
||||
reader = asyncio.StreamReader()
|
||||
connect = _build_connect_payload(keep_alive=60)
|
||||
reader.feed_data(bytes([0x10, len(connect)]) + connect)
|
||||
reader.feed_eof()
|
||||
server._handle_connect = AsyncMock(return_value=(True, 60))
|
||||
|
||||
await server._handle_client(reader, _client_writer(peer="5.6.7.8"))
|
||||
|
||||
server._handle_connect.assert_awaited_once()
|
||||
|
||||
Reference in New Issue
Block a user