test_webhook_printer_status.py 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225
  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 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. @pytest.mark.asyncio
  84. @pytest.mark.integration
  85. async def test_returns_200_when_status_is_none(
  86. self,
  87. async_client: AsyncClient,
  88. api_key_data,
  89. printer_row,
  90. ):
  91. """A registered printer the manager hasn't seen yet returns None from
  92. ``get_status``; the response must still be 200 with sensible
  93. defaults rather than 500."""
  94. with patch(
  95. "backend.app.api.routes.webhook.printer_manager.get_status",
  96. MagicMock(return_value=None),
  97. ):
  98. resp = await async_client.get(
  99. f"/api/v1/webhook/printer/{printer_row.id}/status",
  100. headers={"X-API-Key": api_key_data},
  101. )
  102. assert resp.status_code == 200, resp.text
  103. body = resp.json()
  104. assert body["id"] == printer_row.id
  105. assert body["connected"] is False
  106. assert body["state"] is None
  107. assert body["current_print"] is None
  108. assert body["progress"] is None
  109. assert body["remaining_time"] is None
  110. @pytest.mark.asyncio
  111. @pytest.mark.integration
  112. async def test_returns_404_when_printer_does_not_exist(
  113. self,
  114. async_client: AsyncClient,
  115. api_key_data,
  116. ):
  117. resp = await async_client.get(
  118. "/api/v1/webhook/printer/99999/status",
  119. headers={"X-API-Key": api_key_data},
  120. )
  121. assert resp.status_code == 404
  122. class TestWebhookStopPrint:
  123. """``POST /api/v1/webhook/printer/{id}/stop`` — same dataclass-shape
  124. fix applies to the connection / state precondition checks (#1584)."""
  125. @pytest.mark.asyncio
  126. @pytest.mark.integration
  127. async def test_returns_503_when_disconnected(
  128. self,
  129. async_client: AsyncClient,
  130. api_key_data,
  131. printer_row,
  132. ):
  133. state = PrinterState(connected=False, state="unknown")
  134. with patch(
  135. "backend.app.api.routes.webhook.printer_manager.get_status",
  136. MagicMock(return_value=state),
  137. ):
  138. resp = await async_client.post(
  139. f"/api/v1/webhook/printer/{printer_row.id}/stop",
  140. headers={"X-API-Key": api_key_data},
  141. )
  142. # Pre-fix this would have 500'd on `status.get(...)`. Now it
  143. # cleanly returns the documented 503.
  144. assert resp.status_code == 503
  145. @pytest.mark.asyncio
  146. @pytest.mark.integration
  147. async def test_returns_409_when_not_running(
  148. self,
  149. async_client: AsyncClient,
  150. api_key_data,
  151. printer_row,
  152. ):
  153. state = PrinterState(connected=True, state="FINISH")
  154. with patch(
  155. "backend.app.api.routes.webhook.printer_manager.get_status",
  156. MagicMock(return_value=state),
  157. ):
  158. resp = await async_client.post(
  159. f"/api/v1/webhook/printer/{printer_row.id}/stop",
  160. headers={"X-API-Key": api_key_data},
  161. )
  162. assert resp.status_code == 409
  163. class TestWebhookCancelPrint:
  164. """``POST /api/v1/webhook/printer/{id}/cancel`` — same fix shape."""
  165. @pytest.mark.asyncio
  166. @pytest.mark.integration
  167. async def test_returns_503_when_disconnected(
  168. self,
  169. async_client: AsyncClient,
  170. api_key_data,
  171. printer_row,
  172. ):
  173. state = PrinterState(connected=False, state="unknown")
  174. with patch(
  175. "backend.app.api.routes.webhook.printer_manager.get_status",
  176. MagicMock(return_value=state),
  177. ):
  178. resp = await async_client.post(
  179. f"/api/v1/webhook/printer/{printer_row.id}/cancel",
  180. headers={"X-API-Key": api_key_data},
  181. )
  182. assert resp.status_code == 503
  183. @pytest.mark.asyncio
  184. @pytest.mark.integration
  185. async def test_returns_409_when_not_running_or_paused(
  186. self,
  187. async_client: AsyncClient,
  188. api_key_data,
  189. printer_row,
  190. ):
  191. state = PrinterState(connected=True, state="IDLE")
  192. with patch(
  193. "backend.app.api.routes.webhook.printer_manager.get_status",
  194. MagicMock(return_value=state),
  195. ):
  196. resp = await async_client.post(
  197. f"/api/v1/webhook/printer/{printer_row.id}/cancel",
  198. headers={"X-API-Key": api_key_data},
  199. )
  200. assert resp.status_code == 409