test_webhook_printer_status.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399
  1. """Regression tests for the webhook printer-status / stop / cancel routes.
  2. Pre-fix the routes treated ``printer_manager.get_status(...)``'s return value
  3. as a dict and called ``.get(...)`` on it. The return is a ``PrinterState``
  4. dataclass (``backend/app/services/bambu_mqtt.py``), so the call raised
  5. ``AttributeError`` and surfaced as a generic 500 for any printer that
  6. actually had a status row. See #1584.
  7. """
  8. from unittest.mock import MagicMock, patch
  9. import pytest
  10. from httpx import AsyncClient
  11. from backend.app.services.bambu_mqtt import HMSError, PrinterState
  12. @pytest.fixture
  13. async def api_key_data(async_client: AsyncClient, db_session):
  14. """API key with read_status + control_printer scopes — covers status,
  15. stop, and cancel in a single fixture."""
  16. from backend.app.core.auth import generate_api_key
  17. from backend.app.models.api_key import APIKey
  18. full_key, key_hash, key_prefix = generate_api_key()
  19. api_key = APIKey(
  20. name="webhook-status-test-key",
  21. key_hash=key_hash,
  22. key_prefix=key_prefix,
  23. can_read_status=True,
  24. can_control_printer=True,
  25. enabled=True,
  26. )
  27. db_session.add(api_key)
  28. await db_session.commit()
  29. return full_key
  30. @pytest.fixture
  31. async def printer_row(db_session):
  32. from backend.app.models.printer import Printer
  33. printer = Printer(
  34. name="StatusTest",
  35. ip_address="192.168.1.44",
  36. access_code="12345678",
  37. serial_number="00M00A000000010",
  38. model="P1S",
  39. )
  40. db_session.add(printer)
  41. await db_session.commit()
  42. return printer
  43. class TestWebhookGetPrinterStatus:
  44. """``GET /api/v1/webhook/printer/{id}/status`` — the route reads the
  45. dataclass via attribute access, not ``.get(...)``. Pre-fix the call
  46. raised AttributeError → 500 for every printer with a status row.
  47. """
  48. @pytest.mark.asyncio
  49. @pytest.mark.integration
  50. async def test_returns_200_with_connected_dataclass_status(
  51. self,
  52. async_client: AsyncClient,
  53. api_key_data,
  54. printer_row,
  55. ):
  56. """A live PrinterState dataclass must yield a 200 with the
  57. attributes mapped into the response — this is the exact regression
  58. from #1584 where the dataclass crashed the ``.get(...)`` calls."""
  59. state = PrinterState(
  60. connected=True,
  61. state="RUNNING",
  62. current_print="bench.3mf",
  63. progress=42.0,
  64. remaining_time=1234,
  65. )
  66. with patch(
  67. "backend.app.api.routes.webhook.printer_manager.get_status",
  68. MagicMock(return_value=state),
  69. ):
  70. resp = await async_client.get(
  71. f"/api/v1/webhook/printer/{printer_row.id}/status",
  72. headers={"X-API-Key": api_key_data},
  73. )
  74. assert resp.status_code == 200, resp.text
  75. body = resp.json()
  76. assert body["id"] == printer_row.id
  77. assert body["name"] == "StatusTest"
  78. assert body["connected"] is True
  79. assert body["state"] == "RUNNING"
  80. assert body["current_print"] == "bench.3mf"
  81. assert body["progress"] == 42.0
  82. assert body["remaining_time"] == 1234
  83. assert body["remaining_seconds"] == 1234 * 60
  84. @pytest.mark.asyncio
  85. @pytest.mark.integration
  86. async def test_returns_200_when_status_is_none(
  87. self,
  88. async_client: AsyncClient,
  89. api_key_data,
  90. printer_row,
  91. ):
  92. """A registered printer the manager hasn't seen yet returns None from
  93. ``get_status``; the response must still be 200 with sensible
  94. defaults rather than 500."""
  95. with patch(
  96. "backend.app.api.routes.webhook.printer_manager.get_status",
  97. MagicMock(return_value=None),
  98. ):
  99. resp = await async_client.get(
  100. f"/api/v1/webhook/printer/{printer_row.id}/status",
  101. headers={"X-API-Key": api_key_data},
  102. )
  103. assert resp.status_code == 200, resp.text
  104. body = resp.json()
  105. assert body["id"] == printer_row.id
  106. assert body["connected"] is False
  107. assert body["state"] is None
  108. assert body["current_print"] is None
  109. assert body["progress"] is None
  110. assert body["remaining_time"] is None
  111. assert body["serial_number"] == "00M00A000000010"
  112. assert body["remaining_seconds"] is None
  113. assert body["layer_num"] is None
  114. assert body["total_layers"] is None
  115. assert body["subtask_id"] is None
  116. assert body["hms_errors"] == []
  117. @pytest.mark.asyncio
  118. @pytest.mark.integration
  119. async def test_returns_404_when_printer_does_not_exist(
  120. self,
  121. async_client: AsyncClient,
  122. api_key_data,
  123. ):
  124. resp = await async_client.get(
  125. "/api/v1/webhook/printer/99999/status",
  126. headers={"X-API-Key": api_key_data},
  127. )
  128. assert resp.status_code == 404
  129. async def _status(async_client: AsyncClient, key: str, printer_id: int, state: PrinterState | None):
  130. with patch(
  131. "backend.app.api.routes.webhook.printer_manager.get_status",
  132. MagicMock(return_value=state),
  133. ):
  134. return await async_client.get(
  135. f"/api/v1/webhook/printer/{printer_id}/status",
  136. headers={"X-API-Key": key},
  137. )
  138. class TestWebhookPrinterStatusFields:
  139. """What an external client such as a phone Live Activity needs from the
  140. status route, all of it already on ``PrinterState`` (#2919)."""
  141. @pytest.mark.asyncio
  142. @pytest.mark.integration
  143. async def test_a_running_print_reports_layers_job_and_seconds(
  144. self, async_client: AsyncClient, api_key_data, printer_row
  145. ):
  146. state = PrinterState(
  147. connected=True,
  148. state="RUNNING",
  149. current_print="bench.3mf",
  150. progress=62.0,
  151. remaining_time=107,
  152. layer_num=88,
  153. total_layers=240,
  154. subtask_id="512345678",
  155. )
  156. body = (await _status(async_client, api_key_data, printer_row.id, state)).json()
  157. assert body["serial_number"] == "00M00A000000010"
  158. assert body["layer_num"] == 88
  159. assert body["total_layers"] == 240
  160. assert body["subtask_id"] == "512345678"
  161. # remaining_time stays in minutes for existing clients.
  162. assert body["remaining_time"] == 107
  163. assert body["remaining_seconds"] == 6420
  164. assert body["hms_errors"] == []
  165. @pytest.mark.asyncio
  166. @pytest.mark.integration
  167. async def test_a_paused_print_says_why(self, async_client: AsyncClient, api_key_data, printer_row):
  168. """A runout pause carries its HMS fault; a user pause carries none."""
  169. runout = HMSError(
  170. code="0x20008",
  171. attr=0x07008000,
  172. module=0x07,
  173. severity=2,
  174. description="Filament has run out.",
  175. actions=["RESUME_PRINTING"],
  176. job_id="512345678",
  177. full_code="0700800000020008",
  178. )
  179. state = PrinterState(connected=True, state="PAUSE", subtask_id="512345678", hms_errors=[runout])
  180. body = (await _status(async_client, api_key_data, printer_row.id, state)).json()
  181. assert body["hms_errors"] == [
  182. {
  183. "code": "0x20008",
  184. "attr": 0x07008000,
  185. "module": 0x07,
  186. "severity": 2,
  187. "actions": ["RESUME_PRINTING"],
  188. "job_id": "512345678",
  189. "full_code": "0700800000020008",
  190. "description": "Filament has run out.",
  191. }
  192. ]
  193. @pytest.mark.asyncio
  194. @pytest.mark.integration
  195. async def test_hms_errors_match_the_printer_status_route(
  196. self, async_client: AsyncClient, api_key_data, printer_row
  197. ):
  198. """One shape for a fault, whether the UI or an API key asks."""
  199. from backend.app.api.routes.printers import hms_error_responses as used_by_printers_route
  200. fault = HMSError(code="0x1", attr=0x0300_0100, module=0x03, severity=1, full_code="0300010000000001")
  201. state = PrinterState(connected=True, state="FAILED", hms_errors=[fault])
  202. body = (await _status(async_client, api_key_data, printer_row.id, state)).json()
  203. assert body["hms_errors"] == [e.model_dump() for e in used_by_printers_route([fault])]
  204. @pytest.mark.asyncio
  205. @pytest.mark.integration
  206. async def test_a_numeric_subtask_id_is_returned_as_text(self, async_client: AsyncClient, api_key_data, printer_row):
  207. """Stored as the printer sent it; a number must not 500 the poll."""
  208. state = PrinterState(connected=True, state="RUNNING", subtask_id=512345678)
  209. resp = await _status(async_client, api_key_data, printer_row.id, state)
  210. assert resp.status_code == 200, resp.text
  211. assert resp.json()["subtask_id"] == "512345678"
  212. @pytest.mark.asyncio
  213. @pytest.mark.integration
  214. @pytest.mark.parametrize("raw", ["0", "", " ", 0])
  215. async def test_a_job_without_an_id_reports_null(self, async_client: AsyncClient, api_key_data, printer_row, raw):
  216. """Bambu reports "0" or "" for local prints. Every such print would
  217. share the same "id", so it must not be handed out as one."""
  218. state = PrinterState(connected=True, state="RUNNING", subtask_id=raw)
  219. resp = await _status(async_client, api_key_data, printer_row.id, state)
  220. assert resp.status_code == 200, resp.text
  221. assert resp.json()["subtask_id"] is None
  222. @pytest.mark.asyncio
  223. @pytest.mark.integration
  224. async def test_idle_printer_reports_zero_not_null(self, async_client: AsyncClient, api_key_data, printer_row):
  225. """A connected idle printer has real zeros; null is kept for "no status yet"."""
  226. state = PrinterState(connected=True, state="IDLE")
  227. body = (await _status(async_client, api_key_data, printer_row.id, state)).json()
  228. assert body["remaining_time"] == 0
  229. assert body["remaining_seconds"] == 0
  230. assert body["layer_num"] == 0
  231. assert body["total_layers"] == 0
  232. assert body["subtask_id"] is None
  233. @pytest.mark.asyncio
  234. @pytest.mark.integration
  235. async def test_needs_the_read_status_scope(self, async_client: AsyncClient, db_session, printer_row):
  236. from backend.app.core.auth import generate_api_key
  237. from backend.app.models.api_key import APIKey
  238. full_key, key_hash, key_prefix = generate_api_key()
  239. db_session.add(
  240. APIKey(
  241. name="no-status",
  242. key_hash=key_hash,
  243. key_prefix=key_prefix,
  244. can_read_status=False,
  245. can_queue=True,
  246. enabled=True,
  247. )
  248. )
  249. await db_session.commit()
  250. resp = await _status(async_client, full_key, printer_row.id, PrinterState(connected=True))
  251. assert resp.status_code == 403
  252. @pytest.mark.asyncio
  253. @pytest.mark.integration
  254. async def test_a_key_for_another_printer_sees_nothing(self, async_client: AsyncClient, db_session, printer_row):
  255. """The serial and faults are only for keys allowed on this printer."""
  256. from backend.app.core.auth import generate_api_key
  257. from backend.app.models.api_key import APIKey
  258. full_key, key_hash, key_prefix = generate_api_key()
  259. db_session.add(
  260. APIKey(
  261. name="other-printer",
  262. key_hash=key_hash,
  263. key_prefix=key_prefix,
  264. can_read_status=True,
  265. printer_ids=[printer_row.id + 1000],
  266. enabled=True,
  267. )
  268. )
  269. await db_session.commit()
  270. resp = await _status(async_client, full_key, printer_row.id, PrinterState(connected=True))
  271. assert resp.status_code == 403
  272. assert "00M00A000000010" not in resp.text
  273. class TestWebhookStopPrint:
  274. """``POST /api/v1/webhook/printer/{id}/stop`` — same dataclass-shape
  275. fix applies to the connection / state precondition checks (#1584)."""
  276. @pytest.mark.asyncio
  277. @pytest.mark.integration
  278. async def test_returns_503_when_disconnected(
  279. self,
  280. async_client: AsyncClient,
  281. api_key_data,
  282. printer_row,
  283. ):
  284. state = PrinterState(connected=False, state="unknown")
  285. with patch(
  286. "backend.app.api.routes.webhook.printer_manager.get_status",
  287. MagicMock(return_value=state),
  288. ):
  289. resp = await async_client.post(
  290. f"/api/v1/webhook/printer/{printer_row.id}/stop",
  291. headers={"X-API-Key": api_key_data},
  292. )
  293. # Pre-fix this would have 500'd on `status.get(...)`. Now it
  294. # cleanly returns the documented 503.
  295. assert resp.status_code == 503
  296. @pytest.mark.asyncio
  297. @pytest.mark.integration
  298. async def test_returns_409_when_not_running(
  299. self,
  300. async_client: AsyncClient,
  301. api_key_data,
  302. printer_row,
  303. ):
  304. state = PrinterState(connected=True, state="FINISH")
  305. with patch(
  306. "backend.app.api.routes.webhook.printer_manager.get_status",
  307. MagicMock(return_value=state),
  308. ):
  309. resp = await async_client.post(
  310. f"/api/v1/webhook/printer/{printer_row.id}/stop",
  311. headers={"X-API-Key": api_key_data},
  312. )
  313. assert resp.status_code == 409
  314. class TestWebhookCancelPrint:
  315. """``POST /api/v1/webhook/printer/{id}/cancel`` — same fix shape."""
  316. @pytest.mark.asyncio
  317. @pytest.mark.integration
  318. async def test_returns_503_when_disconnected(
  319. self,
  320. async_client: AsyncClient,
  321. api_key_data,
  322. printer_row,
  323. ):
  324. state = PrinterState(connected=False, state="unknown")
  325. with patch(
  326. "backend.app.api.routes.webhook.printer_manager.get_status",
  327. MagicMock(return_value=state),
  328. ):
  329. resp = await async_client.post(
  330. f"/api/v1/webhook/printer/{printer_row.id}/cancel",
  331. headers={"X-API-Key": api_key_data},
  332. )
  333. assert resp.status_code == 503
  334. @pytest.mark.asyncio
  335. @pytest.mark.integration
  336. async def test_returns_409_when_not_running_or_paused(
  337. self,
  338. async_client: AsyncClient,
  339. api_key_data,
  340. printer_row,
  341. ):
  342. state = PrinterState(connected=True, state="IDLE")
  343. with patch(
  344. "backend.app.api.routes.webhook.printer_manager.get_status",
  345. MagicMock(return_value=state),
  346. ):
  347. resp = await async_client.post(
  348. f"/api/v1/webhook/printer/{printer_row.id}/cancel",
  349. headers={"X-API-Key": api_key_data},
  350. )
  351. assert resp.status_code == 409