test_printers_api.py 55 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335
  1. """Integration tests for Printers API endpoints.
  2. Tests the full request/response cycle for /api/v1/printers/ endpoints.
  3. """
  4. from unittest.mock import MagicMock, patch
  5. import pytest
  6. from httpx import AsyncClient
  7. class TestPrintersAPI:
  8. """Integration tests for /api/v1/printers/ endpoints."""
  9. # ========================================================================
  10. # List endpoints
  11. # ========================================================================
  12. @pytest.mark.asyncio
  13. @pytest.mark.integration
  14. async def test_list_printers_empty(self, async_client: AsyncClient):
  15. """Verify empty list is returned when no printers exist."""
  16. response = await async_client.get("/api/v1/printers/")
  17. assert response.status_code == 200
  18. assert response.json() == []
  19. @pytest.mark.asyncio
  20. @pytest.mark.integration
  21. async def test_list_printers_with_data(self, async_client: AsyncClient, printer_factory, db_session):
  22. """Verify list returns existing printers."""
  23. await printer_factory(name="Test Printer")
  24. response = await async_client.get("/api/v1/printers/")
  25. assert response.status_code == 200
  26. data = response.json()
  27. assert len(data) >= 1
  28. assert any(p["name"] == "Test Printer" for p in data)
  29. # ========================================================================
  30. # Create endpoints
  31. # ========================================================================
  32. @pytest.mark.asyncio
  33. @pytest.mark.integration
  34. async def test_create_printer(self, async_client: AsyncClient):
  35. """Verify printer can be created."""
  36. data = {
  37. "name": "New Printer",
  38. "serial_number": "00M09A111111111",
  39. "ip_address": "192.168.1.100",
  40. "access_code": "12345678",
  41. "is_active": True,
  42. "model": "X1C",
  43. }
  44. response = await async_client.post("/api/v1/printers/", json=data)
  45. assert response.status_code == 200
  46. result = response.json()
  47. assert result["name"] == "New Printer"
  48. assert result["serial_number"] == "00M09A111111111"
  49. assert result["model"] == "X1C"
  50. @pytest.mark.asyncio
  51. @pytest.mark.integration
  52. async def test_create_printer_with_hostname(self, async_client: AsyncClient):
  53. """Verify printer can be created with a hostname instead of IP address."""
  54. data = {
  55. "name": "DNS Printer",
  56. "serial_number": "00M09A555555555",
  57. "ip_address": "printer.local",
  58. "access_code": "12345678",
  59. "model": "P1S",
  60. }
  61. response = await async_client.post("/api/v1/printers/", json=data)
  62. assert response.status_code == 200
  63. result = response.json()
  64. assert result["name"] == "DNS Printer"
  65. assert result["ip_address"] == "printer.local"
  66. @pytest.mark.asyncio
  67. @pytest.mark.integration
  68. async def test_create_printer_with_fqdn(self, async_client: AsyncClient):
  69. """Verify printer can be created with a fully qualified domain name."""
  70. data = {
  71. "name": "FQDN Printer",
  72. "serial_number": "00M09A666666666",
  73. "ip_address": "my-printer.home.lan",
  74. "access_code": "12345678",
  75. "model": "X1C",
  76. }
  77. response = await async_client.post("/api/v1/printers/", json=data)
  78. assert response.status_code == 200
  79. result = response.json()
  80. assert result["ip_address"] == "my-printer.home.lan"
  81. @pytest.mark.asyncio
  82. @pytest.mark.integration
  83. async def test_create_printer_invalid_hostname(self, async_client: AsyncClient):
  84. """Verify invalid hostnames are rejected."""
  85. data = {
  86. "name": "Bad Printer",
  87. "serial_number": "00M09A777777777",
  88. "ip_address": "-invalid",
  89. "access_code": "12345678",
  90. }
  91. response = await async_client.post("/api/v1/printers/", json=data)
  92. assert response.status_code == 422
  93. @pytest.mark.asyncio
  94. @pytest.mark.integration
  95. async def test_create_printer_duplicate_serial(self, async_client: AsyncClient, printer_factory, db_session):
  96. """Verify duplicate serial number is rejected."""
  97. await printer_factory(serial_number="00M09A222222222")
  98. data = {
  99. "name": "Duplicate Printer",
  100. "serial_number": "00M09A222222222",
  101. "ip_address": "192.168.1.101",
  102. "access_code": "12345678",
  103. }
  104. response = await async_client.post("/api/v1/printers/", json=data)
  105. # Should fail due to duplicate serial
  106. assert response.status_code in [400, 409, 422, 500]
  107. # ========================================================================
  108. # Get single endpoint
  109. # ========================================================================
  110. @pytest.mark.asyncio
  111. @pytest.mark.integration
  112. async def test_get_printer(self, async_client: AsyncClient, printer_factory, db_session):
  113. """Verify single printer can be retrieved."""
  114. printer = await printer_factory(name="Get Test Printer")
  115. response = await async_client.get(f"/api/v1/printers/{printer.id}")
  116. assert response.status_code == 200
  117. result = response.json()
  118. assert result["id"] == printer.id
  119. assert result["name"] == "Get Test Printer"
  120. @pytest.mark.asyncio
  121. @pytest.mark.integration
  122. async def test_get_printer_not_found(self, async_client: AsyncClient):
  123. """Verify 404 for non-existent printer."""
  124. response = await async_client.get("/api/v1/printers/9999")
  125. assert response.status_code == 404
  126. # ========================================================================
  127. # Update endpoints
  128. # ========================================================================
  129. @pytest.mark.asyncio
  130. @pytest.mark.integration
  131. async def test_update_printer_name(self, async_client: AsyncClient, printer_factory, db_session):
  132. """Verify printer name can be updated."""
  133. printer = await printer_factory(name="Original Name")
  134. response = await async_client.patch(f"/api/v1/printers/{printer.id}", json={"name": "Updated Name"})
  135. assert response.status_code == 200
  136. assert response.json()["name"] == "Updated Name"
  137. @pytest.mark.asyncio
  138. @pytest.mark.integration
  139. async def test_update_printer_active_status(self, async_client: AsyncClient, printer_factory, db_session):
  140. """Verify printer active status can be updated."""
  141. printer = await printer_factory(is_active=True)
  142. response = await async_client.patch(f"/api/v1/printers/{printer.id}", json={"is_active": False})
  143. assert response.status_code == 200
  144. assert response.json()["is_active"] is False
  145. @pytest.mark.asyncio
  146. @pytest.mark.integration
  147. async def test_update_printer_auto_archive(self, async_client: AsyncClient, printer_factory, db_session):
  148. """Verify auto_archive setting can be updated."""
  149. printer = await printer_factory(auto_archive=True)
  150. response = await async_client.patch(f"/api/v1/printers/{printer.id}", json={"auto_archive": False})
  151. assert response.status_code == 200
  152. assert response.json()["auto_archive"] is False
  153. @pytest.mark.asyncio
  154. @pytest.mark.integration
  155. async def test_update_nonexistent_printer(self, async_client: AsyncClient):
  156. """Verify updating non-existent printer returns 404."""
  157. response = await async_client.patch("/api/v1/printers/9999", json={"name": "New Name"})
  158. assert response.status_code == 404
  159. # ========================================================================
  160. # Delete endpoints
  161. # ========================================================================
  162. @pytest.mark.asyncio
  163. @pytest.mark.integration
  164. async def test_delete_printer(self, async_client: AsyncClient, printer_factory, db_session):
  165. """Verify printer can be deleted."""
  166. printer = await printer_factory()
  167. printer_id = printer.id
  168. response = await async_client.delete(f"/api/v1/printers/{printer_id}")
  169. assert response.status_code == 200
  170. # Verify deleted
  171. response = await async_client.get(f"/api/v1/printers/{printer_id}")
  172. assert response.status_code == 404
  173. @pytest.mark.asyncio
  174. @pytest.mark.integration
  175. async def test_delete_nonexistent_printer(self, async_client: AsyncClient):
  176. """Verify deleting non-existent printer returns 404."""
  177. response = await async_client.delete("/api/v1/printers/9999")
  178. assert response.status_code == 404
  179. # ========================================================================
  180. # Status endpoint
  181. # ========================================================================
  182. @pytest.mark.asyncio
  183. @pytest.mark.integration
  184. async def test_get_printer_status(
  185. self, async_client: AsyncClient, printer_factory, mock_printer_manager, db_session
  186. ):
  187. """Verify printer status can be retrieved."""
  188. printer = await printer_factory()
  189. response = await async_client.get(f"/api/v1/printers/{printer.id}/status")
  190. assert response.status_code == 200
  191. result = response.json()
  192. assert "connected" in result
  193. assert "state" in result
  194. @pytest.mark.asyncio
  195. @pytest.mark.integration
  196. async def test_get_printer_status_not_found(self, async_client: AsyncClient):
  197. """Verify 404 for status of non-existent printer."""
  198. response = await async_client.get("/api/v1/printers/9999/status")
  199. assert response.status_code == 404
  200. @pytest.mark.asyncio
  201. @pytest.mark.integration
  202. async def test_get_printer_status_includes_fila_switch_when_installed(
  203. self, async_client: AsyncClient, printer_factory, db_session
  204. ):
  205. """When the FTS accessory is installed, the status response must include
  206. the fila_switch object with the routing arrays. See #1162.
  207. The accessory is detected from print.device.fila_switch in MQTT;
  208. we feed a PrinterState with FilaSwitchState(installed=True, ...) and
  209. confirm it survives the schema serialization round-trip.
  210. """
  211. from unittest.mock import MagicMock, patch
  212. from backend.app.services.bambu_mqtt import FilaSwitchState, PrinterState
  213. printer = await printer_factory()
  214. state = PrinterState()
  215. state.connected = True
  216. state.state = "IDLE"
  217. state.fila_switch = FilaSwitchState(
  218. installed=True,
  219. in_slots=[-1, 2],
  220. out_extruders=[0, 1],
  221. stat=0,
  222. info=2,
  223. )
  224. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  225. mock_pm.get_status = MagicMock(return_value=state)
  226. mock_pm.is_awaiting_plate_clear = MagicMock(return_value=False)
  227. response = await async_client.get(f"/api/v1/printers/{printer.id}/status")
  228. assert response.status_code == 200
  229. result = response.json()
  230. assert result["fila_switch"] is not None
  231. assert result["fila_switch"]["installed"] is True
  232. assert result["fila_switch"]["in_slots"] == [-1, 2]
  233. assert result["fila_switch"]["out_extruders"] == [0, 1]
  234. assert result["fila_switch"]["stat"] == 0
  235. assert result["fila_switch"]["info"] == 2
  236. @pytest.mark.asyncio
  237. @pytest.mark.integration
  238. async def test_get_printer_status_omits_fila_switch_when_not_installed(
  239. self, async_client: AsyncClient, printer_factory, db_session
  240. ):
  241. """Without the FTS accessory, fila_switch must be null so the frontend
  242. keeps applying the per-extruder filter on regular dual-nozzle printers."""
  243. from unittest.mock import MagicMock, patch
  244. from backend.app.services.bambu_mqtt import PrinterState
  245. printer = await printer_factory()
  246. state = PrinterState()
  247. state.connected = True
  248. state.state = "IDLE"
  249. # default fila_switch — installed = False
  250. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  251. mock_pm.get_status = MagicMock(return_value=state)
  252. mock_pm.is_awaiting_plate_clear = MagicMock(return_value=False)
  253. response = await async_client.get(f"/api/v1/printers/{printer.id}/status")
  254. assert response.status_code == 200
  255. result = response.json()
  256. assert result["fila_switch"] is None
  257. # ========================================================================
  258. # Test connection endpoint
  259. # ========================================================================
  260. class TestPrinterDataIntegrity:
  261. """Tests for printer data integrity."""
  262. @pytest.mark.asyncio
  263. @pytest.mark.integration
  264. async def test_printer_stores_all_fields(self, async_client: AsyncClient, printer_factory, db_session):
  265. """Verify printer stores all fields correctly."""
  266. printer = await printer_factory(
  267. name="Full Test Printer",
  268. serial_number="00M09A444444444",
  269. ip_address="192.168.1.150",
  270. model="P1S",
  271. is_active=True,
  272. auto_archive=False,
  273. )
  274. response = await async_client.get(f"/api/v1/printers/{printer.id}")
  275. assert response.status_code == 200
  276. result = response.json()
  277. assert result["name"] == "Full Test Printer"
  278. assert result["serial_number"] == "00M09A444444444"
  279. assert result["ip_address"] == "192.168.1.150"
  280. assert result["model"] == "P1S"
  281. assert result["is_active"] is True
  282. assert result["auto_archive"] is False
  283. @pytest.mark.asyncio
  284. @pytest.mark.integration
  285. async def test_printer_update_persists(self, async_client: AsyncClient, printer_factory, db_session):
  286. """CRITICAL: Verify printer updates persist."""
  287. printer = await printer_factory(name="Original", is_active=True)
  288. # Update
  289. await async_client.patch(f"/api/v1/printers/{printer.id}", json={"name": "Updated", "is_active": False})
  290. # Verify persistence
  291. response = await async_client.get(f"/api/v1/printers/{printer.id}")
  292. result = response.json()
  293. assert result["name"] == "Updated"
  294. assert result["is_active"] is False
  295. # ========================================================================
  296. # Refresh status endpoint
  297. # ========================================================================
  298. @pytest.mark.asyncio
  299. @pytest.mark.integration
  300. async def test_refresh_status_not_found(self, async_client: AsyncClient):
  301. """Verify 404 for non-existent printer."""
  302. response = await async_client.post("/api/v1/printers/99999/refresh-status")
  303. assert response.status_code == 404
  304. @pytest.mark.asyncio
  305. @pytest.mark.integration
  306. async def test_refresh_status_not_connected(self, async_client: AsyncClient, printer_factory):
  307. """Verify 400 when printer is not connected."""
  308. printer = await printer_factory(name="Disconnected Printer")
  309. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  310. mock_pm.request_status_update.return_value = False
  311. response = await async_client.post(f"/api/v1/printers/{printer.id}/refresh-status")
  312. assert response.status_code == 400
  313. assert "not connected" in response.json()["detail"].lower()
  314. @pytest.mark.asyncio
  315. @pytest.mark.integration
  316. async def test_refresh_status_success(self, async_client: AsyncClient, printer_factory):
  317. """Verify successful refresh request."""
  318. printer = await printer_factory(name="Connected Printer")
  319. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  320. mock_pm.request_status_update.return_value = True
  321. response = await async_client.post(f"/api/v1/printers/{printer.id}/refresh-status")
  322. assert response.status_code == 200
  323. assert response.json()["status"] == "refresh_requested"
  324. mock_pm.request_status_update.assert_called_once_with(printer.id)
  325. # ========================================================================
  326. # Current print user endpoint (Issue #206)
  327. # ========================================================================
  328. @pytest.mark.asyncio
  329. @pytest.mark.integration
  330. async def test_get_current_print_user_not_found(self, async_client: AsyncClient):
  331. """Verify 404 for non-existent printer."""
  332. response = await async_client.get("/api/v1/printers/99999/current-print-user")
  333. assert response.status_code == 404
  334. @pytest.mark.asyncio
  335. @pytest.mark.integration
  336. async def test_get_current_print_user_returns_empty_when_no_user(self, async_client: AsyncClient, printer_factory):
  337. """Verify empty object returned when no user is tracked."""
  338. printer = await printer_factory(name="Test Printer")
  339. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  340. mock_pm.get_current_print_user.return_value = None
  341. response = await async_client.get(f"/api/v1/printers/{printer.id}/current-print-user")
  342. assert response.status_code == 200
  343. assert response.json() == {}
  344. @pytest.mark.asyncio
  345. @pytest.mark.integration
  346. async def test_get_current_print_user_returns_user_info(self, async_client: AsyncClient, printer_factory):
  347. """Verify user info is returned when tracked."""
  348. printer = await printer_factory(name="Test Printer")
  349. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  350. mock_pm.get_current_print_user.return_value = {"user_id": 42, "username": "testuser"}
  351. response = await async_client.get(f"/api/v1/printers/{printer.id}/current-print-user")
  352. assert response.status_code == 200
  353. result = response.json()
  354. assert result["user_id"] == 42
  355. assert result["username"] == "testuser"
  356. class TestPrintControlAPI:
  357. """Integration tests for print control endpoints (stop, pause, resume)."""
  358. # ========================================================================
  359. # Stop print endpoint
  360. # ========================================================================
  361. @pytest.mark.asyncio
  362. @pytest.mark.integration
  363. async def test_stop_print_not_found(self, async_client: AsyncClient):
  364. """Verify 404 for non-existent printer."""
  365. response = await async_client.post("/api/v1/printers/99999/print/stop")
  366. assert response.status_code == 404
  367. @pytest.mark.asyncio
  368. @pytest.mark.integration
  369. async def test_stop_print_not_connected(self, async_client: AsyncClient, printer_factory):
  370. """Verify error when printer is not connected."""
  371. printer = await printer_factory(name="Disconnected Printer")
  372. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  373. mock_pm.get_client.return_value = None
  374. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/stop")
  375. assert response.status_code == 400
  376. assert "not connected" in response.json()["detail"].lower()
  377. @pytest.mark.asyncio
  378. @pytest.mark.integration
  379. async def test_stop_print_success(self, async_client: AsyncClient, printer_factory):
  380. """Verify successful stop print request."""
  381. printer = await printer_factory(name="Printing Printer")
  382. mock_client = MagicMock()
  383. mock_client.stop_print.return_value = True
  384. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  385. mock_pm.get_client.return_value = mock_client
  386. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/stop")
  387. assert response.status_code == 200
  388. assert response.json()["success"] is True
  389. mock_client.stop_print.assert_called_once()
  390. # ========================================================================
  391. # Pause print endpoint
  392. # ========================================================================
  393. @pytest.mark.asyncio
  394. @pytest.mark.integration
  395. async def test_pause_print_not_found(self, async_client: AsyncClient):
  396. """Verify 404 for non-existent printer."""
  397. response = await async_client.post("/api/v1/printers/99999/print/pause")
  398. assert response.status_code == 404
  399. @pytest.mark.asyncio
  400. @pytest.mark.integration
  401. async def test_pause_print_not_connected(self, async_client: AsyncClient, printer_factory):
  402. """Verify error when printer is not connected."""
  403. printer = await printer_factory(name="Disconnected Printer")
  404. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  405. mock_pm.get_client.return_value = None
  406. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/pause")
  407. assert response.status_code == 400
  408. assert "not connected" in response.json()["detail"].lower()
  409. @pytest.mark.asyncio
  410. @pytest.mark.integration
  411. async def test_pause_print_success(self, async_client: AsyncClient, printer_factory):
  412. """Verify successful pause print request."""
  413. printer = await printer_factory(name="Printing Printer")
  414. mock_client = MagicMock()
  415. mock_client.pause_print.return_value = True
  416. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  417. mock_pm.get_client.return_value = mock_client
  418. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/pause")
  419. assert response.status_code == 200
  420. assert response.json()["success"] is True
  421. mock_client.pause_print.assert_called_once()
  422. # ========================================================================
  423. # Resume print endpoint
  424. # ========================================================================
  425. @pytest.mark.asyncio
  426. @pytest.mark.integration
  427. async def test_resume_print_not_found(self, async_client: AsyncClient):
  428. """Verify 404 for non-existent printer."""
  429. response = await async_client.post("/api/v1/printers/99999/print/resume")
  430. assert response.status_code == 404
  431. @pytest.mark.asyncio
  432. @pytest.mark.integration
  433. async def test_resume_print_not_connected(self, async_client: AsyncClient, printer_factory):
  434. """Verify error when printer is not connected."""
  435. printer = await printer_factory(name="Disconnected Printer")
  436. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  437. mock_pm.get_client.return_value = None
  438. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/resume")
  439. assert response.status_code == 400
  440. assert "not connected" in response.json()["detail"].lower()
  441. @pytest.mark.asyncio
  442. @pytest.mark.integration
  443. async def test_resume_print_success(self, async_client: AsyncClient, printer_factory):
  444. """Verify successful resume print request."""
  445. printer = await printer_factory(name="Paused Printer")
  446. mock_client = MagicMock()
  447. mock_client.resume_print.return_value = True
  448. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  449. mock_pm.get_client.return_value = mock_client
  450. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/resume")
  451. assert response.status_code == 200
  452. assert response.json()["success"] is True
  453. mock_client.resume_print.assert_called_once()
  454. class TestAMSRefreshAPI:
  455. """Integration tests for AMS slot refresh endpoint."""
  456. @pytest.mark.asyncio
  457. @pytest.mark.integration
  458. async def test_ams_refresh_not_found(self, async_client: AsyncClient):
  459. """Verify 404 for non-existent printer."""
  460. response = await async_client.post("/api/v1/printers/99999/ams/0/slot/0/refresh")
  461. assert response.status_code == 404
  462. @pytest.mark.asyncio
  463. @pytest.mark.integration
  464. async def test_ams_refresh_not_connected(self, async_client: AsyncClient, printer_factory):
  465. """Verify error when printer is not connected."""
  466. printer = await printer_factory(name="Disconnected Printer")
  467. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  468. mock_pm.get_client.return_value = None
  469. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/0/slot/0/refresh")
  470. assert response.status_code == 400
  471. assert "not connected" in response.json()["detail"].lower()
  472. @pytest.mark.asyncio
  473. @pytest.mark.integration
  474. async def test_ams_refresh_success(self, async_client: AsyncClient, printer_factory):
  475. """Verify successful AMS refresh request."""
  476. printer = await printer_factory(name="Printer with AMS")
  477. mock_client = MagicMock()
  478. mock_client.ams_refresh_tray.return_value = (True, "Refreshing AMS 0 tray 1")
  479. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  480. mock_pm.get_client.return_value = mock_client
  481. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/0/slot/1/refresh")
  482. assert response.status_code == 200
  483. result = response.json()
  484. assert result["success"] is True
  485. mock_client.ams_refresh_tray.assert_called_once_with(0, 1)
  486. @pytest.mark.asyncio
  487. @pytest.mark.integration
  488. async def test_ams_refresh_filament_loaded(self, async_client: AsyncClient, printer_factory):
  489. """Verify error when filament is loaded (can't refresh while loaded)."""
  490. printer = await printer_factory(name="Printer with AMS")
  491. mock_client = MagicMock()
  492. mock_client.ams_refresh_tray.return_value = (False, "Please unload filament first")
  493. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  494. mock_pm.get_client.return_value = mock_client
  495. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/0/slot/0/refresh")
  496. assert response.status_code == 400
  497. assert "unload" in response.json()["detail"].lower()
  498. class TestConfigureAMSSlotAPI:
  499. """Integration tests for AMS slot configure endpoint — tray_info_idx resolution."""
  500. @pytest.mark.asyncio
  501. @pytest.mark.integration
  502. async def test_configure_not_connected(self, async_client: AsyncClient, printer_factory):
  503. """Verify error when printer is not connected."""
  504. printer = await printer_factory(name="Disconnected")
  505. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  506. mock_pm.get_client.return_value = None
  507. response = await async_client.post(
  508. f"/api/v1/printers/{printer.id}/slots/0/0/configure",
  509. params={
  510. "tray_info_idx": "GFL99",
  511. "tray_type": "PLA",
  512. "tray_sub_brands": "PLA Basic",
  513. "tray_color": "FF0000FF",
  514. "nozzle_temp_min": 190,
  515. "nozzle_temp_max": 230,
  516. },
  517. )
  518. assert response.status_code == 400
  519. assert "not connected" in response.json()["detail"].lower()
  520. @pytest.mark.asyncio
  521. @pytest.mark.integration
  522. async def test_configure_with_gf_id_keeps_it(self, async_client: AsyncClient, printer_factory):
  523. """Standard Bambu GF* filament IDs are sent as-is."""
  524. printer = await printer_factory(name="H2D")
  525. mock_client = MagicMock()
  526. mock_client.ams_set_filament_setting.return_value = True
  527. mock_client.extrusion_cali_sel.return_value = True
  528. mock_client.request_status_update.return_value = True
  529. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  530. mock_pm.get_client.return_value = mock_client
  531. mock_pm.get_status.return_value = None # No existing state
  532. response = await async_client.post(
  533. f"/api/v1/printers/{printer.id}/slots/2/3/configure",
  534. params={
  535. "tray_info_idx": "GFL05",
  536. "tray_type": "PLA",
  537. "tray_sub_brands": "PLA Basic",
  538. "tray_color": "FFFFFFFF",
  539. "nozzle_temp_min": 190,
  540. "nozzle_temp_max": 230,
  541. },
  542. )
  543. assert response.status_code == 200
  544. call_kwargs = mock_client.ams_set_filament_setting.call_args
  545. assert call_kwargs.kwargs["tray_info_idx"] == "GFL05"
  546. @pytest.mark.asyncio
  547. @pytest.mark.integration
  548. async def test_configure_pfus_sent_directly(self, async_client: AsyncClient, printer_factory):
  549. """PFUS* cloud-synced custom preset IDs are sent to the printer."""
  550. printer = await printer_factory(name="H2D")
  551. mock_client = MagicMock()
  552. mock_client.ams_set_filament_setting.return_value = True
  553. mock_client.extrusion_cali_sel.return_value = True
  554. mock_client.request_status_update.return_value = True
  555. mock_status = MagicMock()
  556. mock_status.raw_data = {"ams": {"ams": []}} # No existing tray data
  557. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  558. mock_pm.get_client.return_value = mock_client
  559. mock_pm.get_status.return_value = mock_status
  560. response = await async_client.post(
  561. f"/api/v1/printers/{printer.id}/slots/2/3/configure",
  562. params={
  563. "tray_info_idx": "PFUS9ac902733670a9",
  564. "tray_type": "PLA",
  565. "tray_sub_brands": "Devil Design PLA",
  566. "tray_color": "FF0000FF",
  567. "nozzle_temp_min": 190,
  568. "nozzle_temp_max": 230,
  569. },
  570. )
  571. assert response.status_code == 200
  572. call_kwargs = mock_client.ams_set_filament_setting.call_args
  573. assert call_kwargs.kwargs["tray_info_idx"] == "PFUS9ac902733670a9"
  574. @pytest.mark.asyncio
  575. @pytest.mark.integration
  576. async def test_configure_pfus_takes_priority_over_slot(self, async_client: AsyncClient, printer_factory):
  577. """Provided PFUS* preset takes priority over slot's existing preset."""
  578. printer = await printer_factory(name="H2D")
  579. mock_client = MagicMock()
  580. mock_client.ams_set_filament_setting.return_value = True
  581. mock_client.extrusion_cali_sel.return_value = True
  582. mock_client.request_status_update.return_value = True
  583. # Simulate slot already configured by slicer with cloud-synced preset
  584. mock_status = MagicMock()
  585. mock_status.raw_data = {
  586. "ams": {
  587. "ams": [
  588. {
  589. "id": 2,
  590. "tray": [
  591. {
  592. "id": 3,
  593. "tray_info_idx": "P4d64437",
  594. "tray_type": "PLA",
  595. "tray_color": "FF0000FF",
  596. }
  597. ],
  598. }
  599. ]
  600. }
  601. }
  602. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  603. mock_pm.get_client.return_value = mock_client
  604. mock_pm.get_status.return_value = mock_status
  605. response = await async_client.post(
  606. f"/api/v1/printers/{printer.id}/slots/2/3/configure",
  607. params={
  608. "tray_info_idx": "PFUS9ac902733670a9",
  609. "tray_type": "PLA",
  610. "tray_sub_brands": "Devil Design PLA",
  611. "tray_color": "FF0000FF",
  612. "nozzle_temp_min": 190,
  613. "nozzle_temp_max": 230,
  614. },
  615. )
  616. assert response.status_code == 200
  617. call_kwargs = mock_client.ams_set_filament_setting.call_args
  618. # Provided preset wins over slot's existing one
  619. assert call_kwargs.kwargs["tray_info_idx"] == "PFUS9ac902733670a9"
  620. @pytest.mark.asyncio
  621. @pytest.mark.integration
  622. async def test_configure_pfus_used_regardless_of_slot_material(self, async_client: AsyncClient, printer_factory):
  623. """Provided PFUS* preset is used even when slot has a different material."""
  624. printer = await printer_factory(name="H2D")
  625. mock_client = MagicMock()
  626. mock_client.ams_set_filament_setting.return_value = True
  627. mock_client.extrusion_cali_sel.return_value = True
  628. mock_client.request_status_update.return_value = True
  629. # Slot currently has PETG but user is configuring PLA
  630. mock_status = MagicMock()
  631. mock_status.raw_data = {
  632. "ams": {
  633. "ams": [
  634. {
  635. "id": 2,
  636. "tray": [{"id": 3, "tray_info_idx": "GFG99", "tray_type": "PETG", "tray_color": "FFFFFFFF"}],
  637. }
  638. ]
  639. }
  640. }
  641. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  642. mock_pm.get_client.return_value = mock_client
  643. mock_pm.get_status.return_value = mock_status
  644. response = await async_client.post(
  645. f"/api/v1/printers/{printer.id}/slots/2/3/configure",
  646. params={
  647. "tray_info_idx": "PFUS9ac902733670a9",
  648. "tray_type": "PLA",
  649. "tray_sub_brands": "Devil Design PLA",
  650. "tray_color": "FF0000FF",
  651. "nozzle_temp_min": 190,
  652. "nozzle_temp_max": 230,
  653. },
  654. )
  655. assert response.status_code == 200
  656. call_kwargs = mock_client.ams_set_filament_setting.call_args
  657. # Provided preset wins — slot's material is irrelevant
  658. assert call_kwargs.kwargs["tray_info_idx"] == "PFUS9ac902733670a9"
  659. @pytest.mark.asyncio
  660. @pytest.mark.integration
  661. async def test_configure_empty_id_uses_generic(self, async_client: AsyncClient, printer_factory):
  662. """Empty tray_info_idx (local preset) is replaced with generic."""
  663. printer = await printer_factory(name="H2D")
  664. mock_client = MagicMock()
  665. mock_client.ams_set_filament_setting.return_value = True
  666. mock_client.extrusion_cali_sel.return_value = True
  667. mock_client.request_status_update.return_value = True
  668. mock_status = MagicMock()
  669. mock_status.raw_data = {"ams": {"ams": []}}
  670. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  671. mock_pm.get_client.return_value = mock_client
  672. mock_pm.get_status.return_value = mock_status
  673. response = await async_client.post(
  674. f"/api/v1/printers/{printer.id}/slots/2/3/configure",
  675. params={
  676. "tray_info_idx": "",
  677. "tray_type": "PETG",
  678. "tray_sub_brands": "PETG Basic",
  679. "tray_color": "FFFFFFFF",
  680. "nozzle_temp_min": 220,
  681. "nozzle_temp_max": 260,
  682. },
  683. )
  684. assert response.status_code == 200
  685. call_kwargs = mock_client.ams_set_filament_setting.call_args
  686. assert call_kwargs.kwargs["tray_info_idx"] == "GFG99"
  687. @pytest.mark.asyncio
  688. @pytest.mark.integration
  689. async def test_configure_pfus_preserves_setting_id_pair(self, async_client: AsyncClient, printer_factory):
  690. """Both tray_info_idx=PFUS* and setting_id=PFUS* are forwarded untouched.
  691. Pins the end-to-end contract the frontend #1053 fix relies on: when the
  692. user configures a slot with a custom cloud preset whose cloud detail
  693. has filament_id=null, the frontend sends the setting_id in BOTH fields
  694. and the backend must not collapse either to a generic GF* ID.
  695. """
  696. printer = await printer_factory(name="H2D")
  697. mock_client = MagicMock()
  698. mock_client.ams_set_filament_setting.return_value = True
  699. mock_client.extrusion_cali_sel.return_value = True
  700. mock_client.request_status_update.return_value = True
  701. mock_status = MagicMock()
  702. mock_status.raw_data = {"ams": {"ams": []}}
  703. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  704. mock_pm.get_client.return_value = mock_client
  705. mock_pm.get_status.return_value = mock_status
  706. response = await async_client.post(
  707. f"/api/v1/printers/{printer.id}/slots/128/0/configure",
  708. params={
  709. "tray_info_idx": "PFUSa8fb76f9733e3c",
  710. "tray_type": "ABS",
  711. "tray_sub_brands": "Sting3D ABS",
  712. "tray_color": "000000FF",
  713. "nozzle_temp_min": 240,
  714. "nozzle_temp_max": 280,
  715. "setting_id": "PFUSa8fb76f9733e3c",
  716. },
  717. )
  718. assert response.status_code == 200
  719. call_kwargs = mock_client.ams_set_filament_setting.call_args
  720. assert call_kwargs.kwargs["tray_info_idx"] == "PFUSa8fb76f9733e3c"
  721. assert call_kwargs.kwargs["setting_id"] == "PFUSa8fb76f9733e3c"
  722. # Explicitly assert no generic-collapse happened for this HT slot.
  723. assert call_kwargs.kwargs["tray_info_idx"] != "GFB99"
  724. class TestSkipObjectsAPI:
  725. """Integration tests for skip objects endpoints."""
  726. # ========================================================================
  727. # Get printable objects endpoint
  728. # ========================================================================
  729. @pytest.mark.asyncio
  730. @pytest.mark.integration
  731. async def test_get_objects_not_found(self, async_client: AsyncClient):
  732. """Verify 404 for non-existent printer."""
  733. response = await async_client.get("/api/v1/printers/99999/print/objects")
  734. assert response.status_code == 404
  735. @pytest.mark.asyncio
  736. @pytest.mark.integration
  737. async def test_get_objects_not_connected(self, async_client: AsyncClient, printer_factory):
  738. """Verify error when printer is not connected."""
  739. printer = await printer_factory(name="Disconnected Printer")
  740. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  741. mock_pm.get_client.return_value = None
  742. response = await async_client.get(f"/api/v1/printers/{printer.id}/print/objects")
  743. assert response.status_code == 400
  744. assert "not connected" in response.json()["detail"].lower()
  745. @pytest.mark.asyncio
  746. @pytest.mark.integration
  747. async def test_get_objects_empty(self, async_client: AsyncClient, printer_factory):
  748. """Verify empty objects list when no print is active."""
  749. printer = await printer_factory(name="Idle Printer")
  750. mock_client = MagicMock()
  751. mock_client.state.printable_objects = {}
  752. mock_client.state.skipped_objects = []
  753. mock_client.state.state = "IDLE"
  754. mock_client.state.subtask_name = None # Prevent FTP download attempt
  755. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  756. mock_pm.get_client.return_value = mock_client
  757. response = await async_client.get(f"/api/v1/printers/{printer.id}/print/objects")
  758. assert response.status_code == 200
  759. result = response.json()
  760. assert result["objects"] == []
  761. assert result["total"] == 0
  762. assert result["skipped_count"] == 0
  763. assert result["is_printing"] is False
  764. @pytest.mark.asyncio
  765. @pytest.mark.integration
  766. async def test_get_objects_with_data(self, async_client: AsyncClient, printer_factory):
  767. """Verify objects list when print is active."""
  768. printer = await printer_factory(name="Printing Printer")
  769. mock_client = MagicMock()
  770. mock_client.state.printable_objects = {100: "Part A", 200: "Part B", 300: "Part C"}
  771. mock_client.state.skipped_objects = [200]
  772. mock_client.state.state = "RUNNING"
  773. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  774. mock_pm.get_client.return_value = mock_client
  775. response = await async_client.get(f"/api/v1/printers/{printer.id}/print/objects")
  776. assert response.status_code == 200
  777. result = response.json()
  778. assert result["total"] == 3
  779. assert result["skipped_count"] == 1
  780. assert result["is_printing"] is True
  781. # Check objects have correct structure
  782. objects_by_id = {obj["id"]: obj for obj in result["objects"]}
  783. assert objects_by_id[100]["name"] == "Part A"
  784. assert objects_by_id[100]["skipped"] is False
  785. assert objects_by_id[200]["name"] == "Part B"
  786. assert objects_by_id[200]["skipped"] is True
  787. assert objects_by_id[300]["name"] == "Part C"
  788. assert objects_by_id[300]["skipped"] is False
  789. # ========================================================================
  790. # Skip objects endpoint
  791. # ========================================================================
  792. @pytest.mark.asyncio
  793. @pytest.mark.integration
  794. async def test_get_objects_with_positions(self, async_client: AsyncClient, printer_factory):
  795. """Verify objects list includes position data when available."""
  796. printer = await printer_factory(name="Printing Printer")
  797. # New format with position data
  798. mock_client = MagicMock()
  799. mock_client.state.printable_objects = {
  800. 100: {"name": "Part A", "x": 50.0, "y": 100.0},
  801. 200: {"name": "Part B", "x": 150.0, "y": 100.0},
  802. }
  803. mock_client.state.skipped_objects = []
  804. mock_client.state.state = "RUNNING"
  805. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  806. mock_pm.get_client.return_value = mock_client
  807. response = await async_client.get(f"/api/v1/printers/{printer.id}/print/objects")
  808. assert response.status_code == 200
  809. result = response.json()
  810. assert result["total"] == 2
  811. # Check objects have position data
  812. objects_by_id = {obj["id"]: obj for obj in result["objects"]}
  813. assert objects_by_id[100]["name"] == "Part A"
  814. assert objects_by_id[100]["x"] == 50.0
  815. assert objects_by_id[100]["y"] == 100.0
  816. assert objects_by_id[200]["name"] == "Part B"
  817. assert objects_by_id[200]["x"] == 150.0
  818. assert objects_by_id[200]["y"] == 100.0
  819. # ========================================================================
  820. # Skip objects endpoint
  821. # ========================================================================
  822. @pytest.mark.asyncio
  823. @pytest.mark.integration
  824. async def test_skip_objects_not_found(self, async_client: AsyncClient):
  825. """Verify 404 for non-existent printer."""
  826. response = await async_client.post("/api/v1/printers/99999/print/skip-objects", json=[100])
  827. assert response.status_code == 404
  828. @pytest.mark.asyncio
  829. @pytest.mark.integration
  830. async def test_skip_objects_not_connected(self, async_client: AsyncClient, printer_factory):
  831. """Verify error when printer is not connected."""
  832. printer = await printer_factory(name="Disconnected Printer")
  833. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  834. mock_pm.get_client.return_value = None
  835. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/skip-objects", json=[100])
  836. assert response.status_code == 400
  837. assert "not connected" in response.json()["detail"].lower()
  838. @pytest.mark.asyncio
  839. @pytest.mark.integration
  840. async def test_skip_objects_empty_list(self, async_client: AsyncClient, printer_factory):
  841. """Verify error when no object IDs provided."""
  842. printer = await printer_factory(name="Printing Printer")
  843. mock_client = MagicMock()
  844. mock_client.state.printable_objects = {100: "Part A"}
  845. mock_client.state.skipped_objects = []
  846. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  847. mock_pm.get_client.return_value = mock_client
  848. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/skip-objects", json=[])
  849. assert response.status_code == 400
  850. assert "no object" in response.json()["detail"].lower()
  851. @pytest.mark.asyncio
  852. @pytest.mark.integration
  853. async def test_skip_objects_invalid_id(self, async_client: AsyncClient, printer_factory):
  854. """Verify error when object ID doesn't exist."""
  855. printer = await printer_factory(name="Printing Printer")
  856. mock_client = MagicMock()
  857. mock_client.state.printable_objects = {100: "Part A"}
  858. mock_client.state.skipped_objects = []
  859. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  860. mock_pm.get_client.return_value = mock_client
  861. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/skip-objects", json=[999])
  862. assert response.status_code == 400
  863. assert "invalid" in response.json()["detail"].lower()
  864. @pytest.mark.asyncio
  865. @pytest.mark.integration
  866. async def test_skip_objects_success(self, async_client: AsyncClient, printer_factory):
  867. """Verify successful skip objects request."""
  868. printer = await printer_factory(name="Printing Printer")
  869. mock_client = MagicMock()
  870. mock_client.state.printable_objects = {100: "Part A", 200: "Part B"}
  871. mock_client.state.skipped_objects = []
  872. mock_client.skip_objects.return_value = True
  873. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  874. mock_pm.get_client.return_value = mock_client
  875. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/skip-objects", json=[100])
  876. assert response.status_code == 200
  877. result = response.json()
  878. assert result["success"] is True
  879. assert 100 in result["skipped_objects"]
  880. mock_client.skip_objects.assert_called_once_with([100])
  881. @pytest.mark.asyncio
  882. @pytest.mark.integration
  883. async def test_skip_objects_multiple(self, async_client: AsyncClient, printer_factory):
  884. """Verify skipping multiple objects at once."""
  885. printer = await printer_factory(name="Printing Printer")
  886. mock_client = MagicMock()
  887. mock_client.state.printable_objects = {100: "Part A", 200: "Part B", 300: "Part C"}
  888. mock_client.state.skipped_objects = []
  889. mock_client.skip_objects.return_value = True
  890. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  891. mock_pm.get_client.return_value = mock_client
  892. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/skip-objects", json=[100, 200])
  893. assert response.status_code == 200
  894. result = response.json()
  895. assert result["success"] is True
  896. assert 100 in result["skipped_objects"]
  897. assert 200 in result["skipped_objects"]
  898. mock_client.skip_objects.assert_called_once_with([100, 200])
  899. class TestChamberLightAPI:
  900. """Integration tests for chamber light control endpoint."""
  901. @pytest.mark.asyncio
  902. @pytest.mark.integration
  903. async def test_chamber_light_not_found(self, async_client: AsyncClient):
  904. """Verify 404 for non-existent printer."""
  905. response = await async_client.post("/api/v1/printers/99999/chamber-light?on=true")
  906. assert response.status_code == 404
  907. @pytest.mark.asyncio
  908. @pytest.mark.integration
  909. async def test_chamber_light_not_connected(self, async_client: AsyncClient, printer_factory):
  910. """Verify error when printer is not connected."""
  911. printer = await printer_factory(name="Disconnected Printer")
  912. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  913. mock_pm.get_client.return_value = None
  914. response = await async_client.post(f"/api/v1/printers/{printer.id}/chamber-light?on=true")
  915. assert response.status_code == 400
  916. assert "not connected" in response.json()["detail"].lower()
  917. @pytest.mark.asyncio
  918. @pytest.mark.integration
  919. async def test_chamber_light_on_success(self, async_client: AsyncClient, printer_factory):
  920. """Verify successful chamber light on request."""
  921. printer = await printer_factory(name="Test Printer")
  922. mock_client = MagicMock()
  923. mock_client.set_chamber_light.return_value = True
  924. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  925. mock_pm.get_client.return_value = mock_client
  926. response = await async_client.post(f"/api/v1/printers/{printer.id}/chamber-light?on=true")
  927. assert response.status_code == 200
  928. result = response.json()
  929. assert result["success"] is True
  930. assert "on" in result["message"].lower()
  931. mock_client.set_chamber_light.assert_called_once_with(True)
  932. @pytest.mark.asyncio
  933. @pytest.mark.integration
  934. async def test_chamber_light_off_success(self, async_client: AsyncClient, printer_factory):
  935. """Verify successful chamber light off request."""
  936. printer = await printer_factory(name="Test Printer")
  937. mock_client = MagicMock()
  938. mock_client.set_chamber_light.return_value = True
  939. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  940. mock_pm.get_client.return_value = mock_client
  941. response = await async_client.post(f"/api/v1/printers/{printer.id}/chamber-light?on=false")
  942. assert response.status_code == 200
  943. result = response.json()
  944. assert result["success"] is True
  945. assert "off" in result["message"].lower()
  946. mock_client.set_chamber_light.assert_called_once_with(False)
  947. @pytest.mark.asyncio
  948. @pytest.mark.integration
  949. async def test_chamber_light_failure(self, async_client: AsyncClient, printer_factory):
  950. """Verify error handling when chamber light control fails."""
  951. printer = await printer_factory(name="Test Printer")
  952. mock_client = MagicMock()
  953. mock_client.set_chamber_light.return_value = False
  954. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  955. mock_pm.get_client.return_value = mock_client
  956. response = await async_client.post(f"/api/v1/printers/{printer.id}/chamber-light?on=true")
  957. assert response.status_code == 500
  958. assert "failed" in response.json()["detail"].lower()
  959. class TestAirductModeAPI:
  960. """Integration tests for the airduct mode endpoint (P2S/H2*)."""
  961. @pytest.mark.asyncio
  962. @pytest.mark.integration
  963. async def test_invalid_mode_rejected(self, async_client: AsyncClient, printer_factory):
  964. printer = await printer_factory(name="P", model="P2S")
  965. response = await async_client.post(f"/api/v1/printers/{printer.id}/airduct-mode?mode=foo")
  966. assert response.status_code == 400
  967. @pytest.mark.asyncio
  968. @pytest.mark.integration
  969. async def test_not_connected(self, async_client: AsyncClient, printer_factory):
  970. printer = await printer_factory(name="P", model="P2S")
  971. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  972. mock_pm.get_client.return_value = None
  973. response = await async_client.post(f"/api/v1/printers/{printer.id}/airduct-mode?mode=cooling")
  974. assert response.status_code == 400
  975. @pytest.mark.asyncio
  976. @pytest.mark.integration
  977. async def test_cooling_success(self, async_client: AsyncClient, printer_factory):
  978. printer = await printer_factory(name="P", model="P2S")
  979. mock_client = MagicMock()
  980. mock_client.set_airduct_mode.return_value = True
  981. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  982. mock_pm.get_client.return_value = mock_client
  983. response = await async_client.post(f"/api/v1/printers/{printer.id}/airduct-mode?mode=cooling")
  984. assert response.status_code == 200
  985. assert response.json()["success"] is True
  986. mock_client.set_airduct_mode.assert_called_once_with("cooling")
  987. @pytest.mark.asyncio
  988. @pytest.mark.integration
  989. async def test_heating_failure_returns_500(self, async_client: AsyncClient, printer_factory):
  990. printer = await printer_factory(name="P", model="P2S")
  991. mock_client = MagicMock()
  992. mock_client.set_airduct_mode.return_value = False
  993. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  994. mock_pm.get_client.return_value = mock_client
  995. response = await async_client.post(f"/api/v1/printers/{printer.id}/airduct-mode?mode=heating")
  996. assert response.status_code == 500
  997. class TestClearHMSErrorsAPI:
  998. """Integration tests for clear HMS errors endpoint."""
  999. @pytest.mark.asyncio
  1000. @pytest.mark.integration
  1001. async def test_clear_hms_errors_not_found(self, async_client: AsyncClient):
  1002. """Verify 404 for non-existent printer."""
  1003. response = await async_client.post("/api/v1/printers/99999/hms/clear")
  1004. assert response.status_code == 404
  1005. @pytest.mark.asyncio
  1006. @pytest.mark.integration
  1007. async def test_clear_hms_errors_not_connected(self, async_client: AsyncClient, printer_factory):
  1008. """Verify error when printer is not connected."""
  1009. printer = await printer_factory(name="Disconnected Printer")
  1010. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1011. mock_pm.get_client.return_value = None
  1012. response = await async_client.post(f"/api/v1/printers/{printer.id}/hms/clear")
  1013. assert response.status_code == 400
  1014. assert "not connected" in response.json()["detail"].lower()
  1015. @pytest.mark.asyncio
  1016. @pytest.mark.integration
  1017. async def test_clear_hms_errors_success(self, async_client: AsyncClient, printer_factory):
  1018. """Verify successful clear HMS errors request."""
  1019. printer = await printer_factory(name="Test Printer")
  1020. mock_client = MagicMock()
  1021. mock_client.clear_hms_errors.return_value = True
  1022. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1023. mock_pm.get_client.return_value = mock_client
  1024. response = await async_client.post(f"/api/v1/printers/{printer.id}/hms/clear")
  1025. assert response.status_code == 200
  1026. result = response.json()
  1027. assert result["success"] is True
  1028. assert "cleared" in result["message"].lower()
  1029. mock_client.clear_hms_errors.assert_called_once()
  1030. @pytest.mark.asyncio
  1031. @pytest.mark.integration
  1032. async def test_clear_hms_errors_failure(self, async_client: AsyncClient, printer_factory):
  1033. """Verify error handling when clear HMS errors fails."""
  1034. printer = await printer_factory(name="Test Printer")
  1035. mock_client = MagicMock()
  1036. mock_client.clear_hms_errors.return_value = False
  1037. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1038. mock_pm.get_client.return_value = mock_client
  1039. response = await async_client.post(f"/api/v1/printers/{printer.id}/hms/clear")
  1040. assert response.status_code == 500
  1041. assert "failed" in response.json()["detail"].lower()