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:
maziggy
2026-10-19 09:50:39 +02:00
parent 69c2ff1b44
commit 44a5c7a7f5
8 changed files with 561 additions and 179 deletions
+5 -43
View File
@@ -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
+59
View File
@@ -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
+83 -1
View File
@@ -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()