| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399 |
- """Regression tests for the webhook printer-status / stop / cancel routes.
- Pre-fix the routes treated ``printer_manager.get_status(...)``'s return value
- as a dict and called ``.get(...)`` on it. The return is a ``PrinterState``
- dataclass (``backend/app/services/bambu_mqtt.py``), so the call raised
- ``AttributeError`` and surfaced as a generic 500 for any printer that
- actually had a status row. See #1584.
- """
- from unittest.mock import MagicMock, patch
- import pytest
- from httpx import AsyncClient
- from backend.app.services.bambu_mqtt import HMSError, PrinterState
- @pytest.fixture
- async def api_key_data(async_client: AsyncClient, db_session):
- """API key with read_status + control_printer scopes — covers status,
- stop, and cancel in a single fixture."""
- from backend.app.core.auth import generate_api_key
- from backend.app.models.api_key import APIKey
- full_key, key_hash, key_prefix = generate_api_key()
- api_key = APIKey(
- name="webhook-status-test-key",
- key_hash=key_hash,
- key_prefix=key_prefix,
- can_read_status=True,
- can_control_printer=True,
- enabled=True,
- )
- db_session.add(api_key)
- await db_session.commit()
- return full_key
- @pytest.fixture
- async def printer_row(db_session):
- from backend.app.models.printer import Printer
- printer = Printer(
- name="StatusTest",
- ip_address="192.168.1.44",
- access_code="12345678",
- serial_number="00M00A000000010",
- model="P1S",
- )
- db_session.add(printer)
- await db_session.commit()
- return printer
- class TestWebhookGetPrinterStatus:
- """``GET /api/v1/webhook/printer/{id}/status`` — the route reads the
- dataclass via attribute access, not ``.get(...)``. Pre-fix the call
- raised AttributeError → 500 for every printer with a status row.
- """
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_returns_200_with_connected_dataclass_status(
- self,
- async_client: AsyncClient,
- api_key_data,
- printer_row,
- ):
- """A live PrinterState dataclass must yield a 200 with the
- attributes mapped into the response — this is the exact regression
- from #1584 where the dataclass crashed the ``.get(...)`` calls."""
- state = PrinterState(
- connected=True,
- state="RUNNING",
- current_print="bench.3mf",
- progress=42.0,
- remaining_time=1234,
- )
- with patch(
- "backend.app.api.routes.webhook.printer_manager.get_status",
- MagicMock(return_value=state),
- ):
- resp = await async_client.get(
- f"/api/v1/webhook/printer/{printer_row.id}/status",
- headers={"X-API-Key": api_key_data},
- )
- assert resp.status_code == 200, resp.text
- body = resp.json()
- assert body["id"] == printer_row.id
- assert body["name"] == "StatusTest"
- assert body["connected"] is True
- assert body["state"] == "RUNNING"
- assert body["current_print"] == "bench.3mf"
- assert body["progress"] == 42.0
- assert body["remaining_time"] == 1234
- assert body["remaining_seconds"] == 1234 * 60
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_returns_200_when_status_is_none(
- self,
- async_client: AsyncClient,
- api_key_data,
- printer_row,
- ):
- """A registered printer the manager hasn't seen yet returns None from
- ``get_status``; the response must still be 200 with sensible
- defaults rather than 500."""
- with patch(
- "backend.app.api.routes.webhook.printer_manager.get_status",
- MagicMock(return_value=None),
- ):
- resp = await async_client.get(
- f"/api/v1/webhook/printer/{printer_row.id}/status",
- headers={"X-API-Key": api_key_data},
- )
- assert resp.status_code == 200, resp.text
- body = resp.json()
- assert body["id"] == printer_row.id
- assert body["connected"] is False
- assert body["state"] is None
- assert body["current_print"] is None
- assert body["progress"] is None
- assert body["remaining_time"] is None
- assert body["serial_number"] == "00M00A000000010"
- assert body["remaining_seconds"] is None
- assert body["layer_num"] is None
- assert body["total_layers"] is None
- assert body["subtask_id"] is None
- assert body["hms_errors"] == []
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_returns_404_when_printer_does_not_exist(
- self,
- async_client: AsyncClient,
- api_key_data,
- ):
- resp = await async_client.get(
- "/api/v1/webhook/printer/99999/status",
- headers={"X-API-Key": api_key_data},
- )
- assert resp.status_code == 404
- async def _status(async_client: AsyncClient, key: str, printer_id: int, state: PrinterState | None):
- with patch(
- "backend.app.api.routes.webhook.printer_manager.get_status",
- MagicMock(return_value=state),
- ):
- return await async_client.get(
- f"/api/v1/webhook/printer/{printer_id}/status",
- headers={"X-API-Key": key},
- )
- class TestWebhookPrinterStatusFields:
- """What an external client such as a phone Live Activity needs from the
- status route, all of it already on ``PrinterState`` (#2919)."""
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_a_running_print_reports_layers_job_and_seconds(
- self, async_client: AsyncClient, api_key_data, printer_row
- ):
- state = PrinterState(
- connected=True,
- state="RUNNING",
- current_print="bench.3mf",
- progress=62.0,
- remaining_time=107,
- layer_num=88,
- total_layers=240,
- subtask_id="512345678",
- )
- body = (await _status(async_client, api_key_data, printer_row.id, state)).json()
- assert body["serial_number"] == "00M00A000000010"
- assert body["layer_num"] == 88
- assert body["total_layers"] == 240
- assert body["subtask_id"] == "512345678"
- # remaining_time stays in minutes for existing clients.
- assert body["remaining_time"] == 107
- assert body["remaining_seconds"] == 6420
- assert body["hms_errors"] == []
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_a_paused_print_says_why(self, async_client: AsyncClient, api_key_data, printer_row):
- """A runout pause carries its HMS fault; a user pause carries none."""
- runout = HMSError(
- code="0x20008",
- attr=0x07008000,
- module=0x07,
- severity=2,
- description="Filament has run out.",
- actions=["RESUME_PRINTING"],
- job_id="512345678",
- full_code="0700800000020008",
- )
- state = PrinterState(connected=True, state="PAUSE", subtask_id="512345678", hms_errors=[runout])
- body = (await _status(async_client, api_key_data, printer_row.id, state)).json()
- assert body["hms_errors"] == [
- {
- "code": "0x20008",
- "attr": 0x07008000,
- "module": 0x07,
- "severity": 2,
- "actions": ["RESUME_PRINTING"],
- "job_id": "512345678",
- "full_code": "0700800000020008",
- "description": "Filament has run out.",
- }
- ]
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_hms_errors_match_the_printer_status_route(
- self, async_client: AsyncClient, api_key_data, printer_row
- ):
- """One shape for a fault, whether the UI or an API key asks."""
- from backend.app.api.routes.printers import hms_error_responses as used_by_printers_route
- fault = HMSError(code="0x1", attr=0x0300_0100, module=0x03, severity=1, full_code="0300010000000001")
- state = PrinterState(connected=True, state="FAILED", hms_errors=[fault])
- body = (await _status(async_client, api_key_data, printer_row.id, state)).json()
- assert body["hms_errors"] == [e.model_dump() for e in used_by_printers_route([fault])]
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_a_numeric_subtask_id_is_returned_as_text(self, async_client: AsyncClient, api_key_data, printer_row):
- """Stored as the printer sent it; a number must not 500 the poll."""
- state = PrinterState(connected=True, state="RUNNING", subtask_id=512345678)
- resp = await _status(async_client, api_key_data, printer_row.id, state)
- assert resp.status_code == 200, resp.text
- assert resp.json()["subtask_id"] == "512345678"
- @pytest.mark.asyncio
- @pytest.mark.integration
- @pytest.mark.parametrize("raw", ["0", "", " ", 0])
- async def test_a_job_without_an_id_reports_null(self, async_client: AsyncClient, api_key_data, printer_row, raw):
- """Bambu reports "0" or "" for local prints. Every such print would
- share the same "id", so it must not be handed out as one."""
- state = PrinterState(connected=True, state="RUNNING", subtask_id=raw)
- resp = await _status(async_client, api_key_data, printer_row.id, state)
- assert resp.status_code == 200, resp.text
- assert resp.json()["subtask_id"] is None
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_idle_printer_reports_zero_not_null(self, async_client: AsyncClient, api_key_data, printer_row):
- """A connected idle printer has real zeros; null is kept for "no status yet"."""
- state = PrinterState(connected=True, state="IDLE")
- body = (await _status(async_client, api_key_data, printer_row.id, state)).json()
- assert body["remaining_time"] == 0
- assert body["remaining_seconds"] == 0
- assert body["layer_num"] == 0
- assert body["total_layers"] == 0
- assert body["subtask_id"] is None
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_needs_the_read_status_scope(self, async_client: AsyncClient, db_session, printer_row):
- from backend.app.core.auth import generate_api_key
- from backend.app.models.api_key import APIKey
- full_key, key_hash, key_prefix = generate_api_key()
- db_session.add(
- APIKey(
- name="no-status",
- key_hash=key_hash,
- key_prefix=key_prefix,
- can_read_status=False,
- can_queue=True,
- enabled=True,
- )
- )
- await db_session.commit()
- resp = await _status(async_client, full_key, printer_row.id, PrinterState(connected=True))
- assert resp.status_code == 403
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_a_key_for_another_printer_sees_nothing(self, async_client: AsyncClient, db_session, printer_row):
- """The serial and faults are only for keys allowed on this printer."""
- from backend.app.core.auth import generate_api_key
- from backend.app.models.api_key import APIKey
- full_key, key_hash, key_prefix = generate_api_key()
- db_session.add(
- APIKey(
- name="other-printer",
- key_hash=key_hash,
- key_prefix=key_prefix,
- can_read_status=True,
- printer_ids=[printer_row.id + 1000],
- enabled=True,
- )
- )
- await db_session.commit()
- resp = await _status(async_client, full_key, printer_row.id, PrinterState(connected=True))
- assert resp.status_code == 403
- assert "00M00A000000010" not in resp.text
- class TestWebhookStopPrint:
- """``POST /api/v1/webhook/printer/{id}/stop`` — same dataclass-shape
- fix applies to the connection / state precondition checks (#1584)."""
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_returns_503_when_disconnected(
- self,
- async_client: AsyncClient,
- api_key_data,
- printer_row,
- ):
- state = PrinterState(connected=False, state="unknown")
- with patch(
- "backend.app.api.routes.webhook.printer_manager.get_status",
- MagicMock(return_value=state),
- ):
- resp = await async_client.post(
- f"/api/v1/webhook/printer/{printer_row.id}/stop",
- headers={"X-API-Key": api_key_data},
- )
- # Pre-fix this would have 500'd on `status.get(...)`. Now it
- # cleanly returns the documented 503.
- assert resp.status_code == 503
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_returns_409_when_not_running(
- self,
- async_client: AsyncClient,
- api_key_data,
- printer_row,
- ):
- state = PrinterState(connected=True, state="FINISH")
- with patch(
- "backend.app.api.routes.webhook.printer_manager.get_status",
- MagicMock(return_value=state),
- ):
- resp = await async_client.post(
- f"/api/v1/webhook/printer/{printer_row.id}/stop",
- headers={"X-API-Key": api_key_data},
- )
- assert resp.status_code == 409
- class TestWebhookCancelPrint:
- """``POST /api/v1/webhook/printer/{id}/cancel`` — same fix shape."""
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_returns_503_when_disconnected(
- self,
- async_client: AsyncClient,
- api_key_data,
- printer_row,
- ):
- state = PrinterState(connected=False, state="unknown")
- with patch(
- "backend.app.api.routes.webhook.printer_manager.get_status",
- MagicMock(return_value=state),
- ):
- resp = await async_client.post(
- f"/api/v1/webhook/printer/{printer_row.id}/cancel",
- headers={"X-API-Key": api_key_data},
- )
- assert resp.status_code == 503
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_returns_409_when_not_running_or_paused(
- self,
- async_client: AsyncClient,
- api_key_data,
- printer_row,
- ):
- state = PrinterState(connected=True, state="IDLE")
- with patch(
- "backend.app.api.routes.webhook.printer_manager.get_status",
- MagicMock(return_value=state),
- ):
- resp = await async_client.post(
- f"/api/v1/webhook/printer/{printer_row.id}/cancel",
- headers={"X-API-Key": api_key_data},
- )
- assert resp.status_code == 409
|