test_printers_api.py 59 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423
  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_cover_uses_dispatched_plate_when_gcode_file_lacks_path(
  239. self, async_client: AsyncClient, printer_factory, db_session, tmp_path
  240. ):
  241. """When firmware drops the plate path from gcode_file (e.g. P1S
  242. 01.10.00.00, #1166), the dispatched-plate record must take precedence
  243. and serve plate 4's thumbnail instead of falling back to plate_1.png."""
  244. import io
  245. import zipfile
  246. from unittest.mock import MagicMock, patch
  247. from backend.app.services.bambu_ftp import cache_3mf_download
  248. from backend.app.services.bambu_mqtt import PrinterState
  249. printer = await printer_factory()
  250. # Build a 3MF that mimics a "true" multi-plate archive: thumbnails
  251. # for plates 1..4 are all present, gcode files for plates 1..4 are
  252. # all present. Without the dispatch record we'd default to plate_1.png.
  253. threemf_path = tmp_path / "MyModel.3mf"
  254. with zipfile.ZipFile(threemf_path, "w") as zf:
  255. for plate in range(1, 5):
  256. zf.writestr(f"Metadata/plate_{plate}.png", f"PLATE_{plate}_PNG".encode())
  257. zf.writestr(f"Metadata/plate_{plate}.gcode", f"; plate {plate} gcode\n")
  258. cache_3mf_download(printer.id, "MyModel.3mf", threemf_path)
  259. state = PrinterState()
  260. state.connected = True
  261. state.state = "RUNNING"
  262. state.subtask_name = "MyModel"
  263. state.gcode_file = "MyModel.3mf" # firmware drops plate path
  264. state.dispatched_plate_id = 4
  265. state.dispatched_subtask = "MyModel"
  266. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  267. mock_pm.get_status = MagicMock(return_value=state)
  268. mock_pm.is_awaiting_plate_clear = MagicMock(return_value=False)
  269. response = await async_client.get(f"/api/v1/printers/{printer.id}/cover")
  270. assert response.status_code == 200
  271. assert response.content == b"PLATE_4_PNG"
  272. @pytest.mark.asyncio
  273. @pytest.mark.integration
  274. async def test_cover_3mf_scan_fallback_for_per_plate_archive(
  275. self, async_client: AsyncClient, printer_factory, db_session, tmp_path
  276. ):
  277. """Per-plate archives sliced separately in Bambu Studio contain a
  278. single Metadata/plate_N.gcode (the active plate) but bundle thumbnails
  279. for every plate. With no dispatch record (e.g. dispatched via Studio
  280. directly) and no plate path in gcode_file, the route must scan the
  281. 3MF and pick plate N's thumbnail. See #1166 option 4."""
  282. import zipfile
  283. from unittest.mock import MagicMock, patch
  284. from backend.app.services.bambu_ftp import cache_3mf_download
  285. from backend.app.services.bambu_mqtt import PrinterState
  286. printer = await printer_factory()
  287. # Per-plate archive: thumbnails for all plates, gcode for plate 3 only.
  288. threemf_path = tmp_path / "PerPlate.3mf"
  289. with zipfile.ZipFile(threemf_path, "w") as zf:
  290. for plate in range(1, 5):
  291. zf.writestr(f"Metadata/plate_{plate}.png", f"PLATE_{plate}_PNG".encode())
  292. zf.writestr("Metadata/plate_3.gcode", "; only plate 3 has gcode\n")
  293. cache_3mf_download(printer.id, "PerPlate.3mf", threemf_path)
  294. state = PrinterState()
  295. state.connected = True
  296. state.state = "RUNNING"
  297. state.subtask_name = "PerPlate"
  298. state.gcode_file = "PerPlate.3mf"
  299. # No dispatch record (Studio-direct dispatch).
  300. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  301. mock_pm.get_status = MagicMock(return_value=state)
  302. mock_pm.is_awaiting_plate_clear = MagicMock(return_value=False)
  303. response = await async_client.get(f"/api/v1/printers/{printer.id}/cover")
  304. assert response.status_code == 200
  305. assert response.content == b"PLATE_3_PNG"
  306. @pytest.mark.asyncio
  307. @pytest.mark.integration
  308. async def test_get_printer_status_omits_fila_switch_when_not_installed(
  309. self, async_client: AsyncClient, printer_factory, db_session
  310. ):
  311. """Without the FTS accessory, fila_switch must be null so the frontend
  312. keeps applying the per-extruder filter on regular dual-nozzle printers."""
  313. from unittest.mock import MagicMock, patch
  314. from backend.app.services.bambu_mqtt import PrinterState
  315. printer = await printer_factory()
  316. state = PrinterState()
  317. state.connected = True
  318. state.state = "IDLE"
  319. # default fila_switch — installed = False
  320. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  321. mock_pm.get_status = MagicMock(return_value=state)
  322. mock_pm.is_awaiting_plate_clear = MagicMock(return_value=False)
  323. response = await async_client.get(f"/api/v1/printers/{printer.id}/status")
  324. assert response.status_code == 200
  325. result = response.json()
  326. assert result["fila_switch"] is None
  327. # ========================================================================
  328. # Test connection endpoint
  329. # ========================================================================
  330. class TestPrinterDataIntegrity:
  331. """Tests for printer data integrity."""
  332. @pytest.mark.asyncio
  333. @pytest.mark.integration
  334. async def test_printer_stores_all_fields(self, async_client: AsyncClient, printer_factory, db_session):
  335. """Verify printer stores all fields correctly."""
  336. printer = await printer_factory(
  337. name="Full Test Printer",
  338. serial_number="00M09A444444444",
  339. ip_address="192.168.1.150",
  340. model="P1S",
  341. is_active=True,
  342. auto_archive=False,
  343. )
  344. response = await async_client.get(f"/api/v1/printers/{printer.id}")
  345. assert response.status_code == 200
  346. result = response.json()
  347. assert result["name"] == "Full Test Printer"
  348. assert result["serial_number"] == "00M09A444444444"
  349. assert result["ip_address"] == "192.168.1.150"
  350. assert result["model"] == "P1S"
  351. assert result["is_active"] is True
  352. assert result["auto_archive"] is False
  353. @pytest.mark.asyncio
  354. @pytest.mark.integration
  355. async def test_printer_update_persists(self, async_client: AsyncClient, printer_factory, db_session):
  356. """CRITICAL: Verify printer updates persist."""
  357. printer = await printer_factory(name="Original", is_active=True)
  358. # Update
  359. await async_client.patch(f"/api/v1/printers/{printer.id}", json={"name": "Updated", "is_active": False})
  360. # Verify persistence
  361. response = await async_client.get(f"/api/v1/printers/{printer.id}")
  362. result = response.json()
  363. assert result["name"] == "Updated"
  364. assert result["is_active"] is False
  365. # ========================================================================
  366. # Refresh status endpoint
  367. # ========================================================================
  368. @pytest.mark.asyncio
  369. @pytest.mark.integration
  370. async def test_refresh_status_not_found(self, async_client: AsyncClient):
  371. """Verify 404 for non-existent printer."""
  372. response = await async_client.post("/api/v1/printers/99999/refresh-status")
  373. assert response.status_code == 404
  374. @pytest.mark.asyncio
  375. @pytest.mark.integration
  376. async def test_refresh_status_not_connected(self, async_client: AsyncClient, printer_factory):
  377. """Verify 400 when printer is not connected."""
  378. printer = await printer_factory(name="Disconnected Printer")
  379. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  380. mock_pm.request_status_update.return_value = False
  381. response = await async_client.post(f"/api/v1/printers/{printer.id}/refresh-status")
  382. assert response.status_code == 400
  383. assert "not connected" in response.json()["detail"].lower()
  384. @pytest.mark.asyncio
  385. @pytest.mark.integration
  386. async def test_refresh_status_success(self, async_client: AsyncClient, printer_factory):
  387. """Verify successful refresh request."""
  388. printer = await printer_factory(name="Connected Printer")
  389. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  390. mock_pm.request_status_update.return_value = True
  391. response = await async_client.post(f"/api/v1/printers/{printer.id}/refresh-status")
  392. assert response.status_code == 200
  393. assert response.json()["status"] == "refresh_requested"
  394. mock_pm.request_status_update.assert_called_once_with(printer.id)
  395. # ========================================================================
  396. # Current print user endpoint (Issue #206)
  397. # ========================================================================
  398. @pytest.mark.asyncio
  399. @pytest.mark.integration
  400. async def test_get_current_print_user_not_found(self, async_client: AsyncClient):
  401. """Verify 404 for non-existent printer."""
  402. response = await async_client.get("/api/v1/printers/99999/current-print-user")
  403. assert response.status_code == 404
  404. @pytest.mark.asyncio
  405. @pytest.mark.integration
  406. async def test_get_current_print_user_returns_empty_when_no_user(self, async_client: AsyncClient, printer_factory):
  407. """Verify empty object returned when no user is tracked."""
  408. printer = await printer_factory(name="Test Printer")
  409. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  410. mock_pm.get_current_print_user.return_value = None
  411. response = await async_client.get(f"/api/v1/printers/{printer.id}/current-print-user")
  412. assert response.status_code == 200
  413. assert response.json() == {}
  414. @pytest.mark.asyncio
  415. @pytest.mark.integration
  416. async def test_get_current_print_user_returns_user_info(self, async_client: AsyncClient, printer_factory):
  417. """Verify user info is returned when tracked."""
  418. printer = await printer_factory(name="Test Printer")
  419. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  420. mock_pm.get_current_print_user.return_value = {"user_id": 42, "username": "testuser"}
  421. response = await async_client.get(f"/api/v1/printers/{printer.id}/current-print-user")
  422. assert response.status_code == 200
  423. result = response.json()
  424. assert result["user_id"] == 42
  425. assert result["username"] == "testuser"
  426. class TestPrintControlAPI:
  427. """Integration tests for print control endpoints (stop, pause, resume)."""
  428. # ========================================================================
  429. # Stop print endpoint
  430. # ========================================================================
  431. @pytest.mark.asyncio
  432. @pytest.mark.integration
  433. async def test_stop_print_not_found(self, async_client: AsyncClient):
  434. """Verify 404 for non-existent printer."""
  435. response = await async_client.post("/api/v1/printers/99999/print/stop")
  436. assert response.status_code == 404
  437. @pytest.mark.asyncio
  438. @pytest.mark.integration
  439. async def test_stop_print_not_connected(self, async_client: AsyncClient, printer_factory):
  440. """Verify error when printer is not connected."""
  441. printer = await printer_factory(name="Disconnected Printer")
  442. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  443. mock_pm.get_client.return_value = None
  444. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/stop")
  445. assert response.status_code == 400
  446. assert "not connected" in response.json()["detail"].lower()
  447. @pytest.mark.asyncio
  448. @pytest.mark.integration
  449. async def test_stop_print_success(self, async_client: AsyncClient, printer_factory):
  450. """Verify successful stop print request."""
  451. printer = await printer_factory(name="Printing Printer")
  452. mock_client = MagicMock()
  453. mock_client.stop_print.return_value = True
  454. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  455. mock_pm.get_client.return_value = mock_client
  456. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/stop")
  457. assert response.status_code == 200
  458. assert response.json()["success"] is True
  459. mock_client.stop_print.assert_called_once()
  460. # ========================================================================
  461. # Pause print endpoint
  462. # ========================================================================
  463. @pytest.mark.asyncio
  464. @pytest.mark.integration
  465. async def test_pause_print_not_found(self, async_client: AsyncClient):
  466. """Verify 404 for non-existent printer."""
  467. response = await async_client.post("/api/v1/printers/99999/print/pause")
  468. assert response.status_code == 404
  469. @pytest.mark.asyncio
  470. @pytest.mark.integration
  471. async def test_pause_print_not_connected(self, async_client: AsyncClient, printer_factory):
  472. """Verify error when printer is not connected."""
  473. printer = await printer_factory(name="Disconnected Printer")
  474. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  475. mock_pm.get_client.return_value = None
  476. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/pause")
  477. assert response.status_code == 400
  478. assert "not connected" in response.json()["detail"].lower()
  479. @pytest.mark.asyncio
  480. @pytest.mark.integration
  481. async def test_pause_print_success(self, async_client: AsyncClient, printer_factory):
  482. """Verify successful pause print request."""
  483. printer = await printer_factory(name="Printing Printer")
  484. mock_client = MagicMock()
  485. mock_client.pause_print.return_value = True
  486. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  487. mock_pm.get_client.return_value = mock_client
  488. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/pause")
  489. assert response.status_code == 200
  490. assert response.json()["success"] is True
  491. mock_client.pause_print.assert_called_once()
  492. # ========================================================================
  493. # Resume print endpoint
  494. # ========================================================================
  495. @pytest.mark.asyncio
  496. @pytest.mark.integration
  497. async def test_resume_print_not_found(self, async_client: AsyncClient):
  498. """Verify 404 for non-existent printer."""
  499. response = await async_client.post("/api/v1/printers/99999/print/resume")
  500. assert response.status_code == 404
  501. @pytest.mark.asyncio
  502. @pytest.mark.integration
  503. async def test_resume_print_not_connected(self, async_client: AsyncClient, printer_factory):
  504. """Verify error when printer is not connected."""
  505. printer = await printer_factory(name="Disconnected Printer")
  506. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  507. mock_pm.get_client.return_value = None
  508. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/resume")
  509. assert response.status_code == 400
  510. assert "not connected" in response.json()["detail"].lower()
  511. @pytest.mark.asyncio
  512. @pytest.mark.integration
  513. async def test_resume_print_success(self, async_client: AsyncClient, printer_factory):
  514. """Verify successful resume print request."""
  515. printer = await printer_factory(name="Paused Printer")
  516. mock_client = MagicMock()
  517. mock_client.resume_print.return_value = True
  518. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  519. mock_pm.get_client.return_value = mock_client
  520. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/resume")
  521. assert response.status_code == 200
  522. assert response.json()["success"] is True
  523. mock_client.resume_print.assert_called_once()
  524. class TestAMSRefreshAPI:
  525. """Integration tests for AMS slot refresh endpoint."""
  526. @pytest.mark.asyncio
  527. @pytest.mark.integration
  528. async def test_ams_refresh_not_found(self, async_client: AsyncClient):
  529. """Verify 404 for non-existent printer."""
  530. response = await async_client.post("/api/v1/printers/99999/ams/0/slot/0/refresh")
  531. assert response.status_code == 404
  532. @pytest.mark.asyncio
  533. @pytest.mark.integration
  534. async def test_ams_refresh_not_connected(self, async_client: AsyncClient, printer_factory):
  535. """Verify error when printer is not connected."""
  536. printer = await printer_factory(name="Disconnected Printer")
  537. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  538. mock_pm.get_client.return_value = None
  539. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/0/slot/0/refresh")
  540. assert response.status_code == 400
  541. assert "not connected" in response.json()["detail"].lower()
  542. @pytest.mark.asyncio
  543. @pytest.mark.integration
  544. async def test_ams_refresh_success(self, async_client: AsyncClient, printer_factory):
  545. """Verify successful AMS refresh request."""
  546. printer = await printer_factory(name="Printer with AMS")
  547. mock_client = MagicMock()
  548. mock_client.ams_refresh_tray.return_value = (True, "Refreshing AMS 0 tray 1")
  549. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  550. mock_pm.get_client.return_value = mock_client
  551. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/0/slot/1/refresh")
  552. assert response.status_code == 200
  553. result = response.json()
  554. assert result["success"] is True
  555. mock_client.ams_refresh_tray.assert_called_once_with(0, 1)
  556. @pytest.mark.asyncio
  557. @pytest.mark.integration
  558. async def test_ams_refresh_filament_loaded(self, async_client: AsyncClient, printer_factory):
  559. """Verify error when filament is loaded (can't refresh while loaded)."""
  560. printer = await printer_factory(name="Printer with AMS")
  561. mock_client = MagicMock()
  562. mock_client.ams_refresh_tray.return_value = (False, "Please unload filament first")
  563. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  564. mock_pm.get_client.return_value = mock_client
  565. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/0/slot/0/refresh")
  566. assert response.status_code == 400
  567. assert "unload" in response.json()["detail"].lower()
  568. class TestConfigureAMSSlotAPI:
  569. """Integration tests for AMS slot configure endpoint — tray_info_idx resolution."""
  570. @pytest.mark.asyncio
  571. @pytest.mark.integration
  572. async def test_configure_not_connected(self, async_client: AsyncClient, printer_factory):
  573. """Verify error when printer is not connected."""
  574. printer = await printer_factory(name="Disconnected")
  575. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  576. mock_pm.get_client.return_value = None
  577. response = await async_client.post(
  578. f"/api/v1/printers/{printer.id}/slots/0/0/configure",
  579. params={
  580. "tray_info_idx": "GFL99",
  581. "tray_type": "PLA",
  582. "tray_sub_brands": "PLA Basic",
  583. "tray_color": "FF0000FF",
  584. "nozzle_temp_min": 190,
  585. "nozzle_temp_max": 230,
  586. },
  587. )
  588. assert response.status_code == 400
  589. assert "not connected" in response.json()["detail"].lower()
  590. @pytest.mark.asyncio
  591. @pytest.mark.integration
  592. async def test_configure_with_gf_id_keeps_it(self, async_client: AsyncClient, printer_factory):
  593. """Standard Bambu GF* filament IDs are sent as-is."""
  594. printer = await printer_factory(name="H2D")
  595. mock_client = MagicMock()
  596. mock_client.ams_set_filament_setting.return_value = True
  597. mock_client.extrusion_cali_sel.return_value = True
  598. mock_client.request_status_update.return_value = True
  599. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  600. mock_pm.get_client.return_value = mock_client
  601. mock_pm.get_status.return_value = None # No existing state
  602. response = await async_client.post(
  603. f"/api/v1/printers/{printer.id}/slots/2/3/configure",
  604. params={
  605. "tray_info_idx": "GFL05",
  606. "tray_type": "PLA",
  607. "tray_sub_brands": "PLA Basic",
  608. "tray_color": "FFFFFFFF",
  609. "nozzle_temp_min": 190,
  610. "nozzle_temp_max": 230,
  611. },
  612. )
  613. assert response.status_code == 200
  614. call_kwargs = mock_client.ams_set_filament_setting.call_args
  615. assert call_kwargs.kwargs["tray_info_idx"] == "GFL05"
  616. @pytest.mark.asyncio
  617. @pytest.mark.integration
  618. async def test_configure_pfus_sent_directly(self, async_client: AsyncClient, printer_factory):
  619. """PFUS* cloud-synced custom preset IDs are sent to the printer."""
  620. printer = await printer_factory(name="H2D")
  621. mock_client = MagicMock()
  622. mock_client.ams_set_filament_setting.return_value = True
  623. mock_client.extrusion_cali_sel.return_value = True
  624. mock_client.request_status_update.return_value = True
  625. mock_status = MagicMock()
  626. mock_status.raw_data = {"ams": {"ams": []}} # No existing tray data
  627. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  628. mock_pm.get_client.return_value = mock_client
  629. mock_pm.get_status.return_value = mock_status
  630. response = await async_client.post(
  631. f"/api/v1/printers/{printer.id}/slots/2/3/configure",
  632. params={
  633. "tray_info_idx": "PFUS9ac902733670a9",
  634. "tray_type": "PLA",
  635. "tray_sub_brands": "Devil Design PLA",
  636. "tray_color": "FF0000FF",
  637. "nozzle_temp_min": 190,
  638. "nozzle_temp_max": 230,
  639. },
  640. )
  641. assert response.status_code == 200
  642. call_kwargs = mock_client.ams_set_filament_setting.call_args
  643. assert call_kwargs.kwargs["tray_info_idx"] == "PFUS9ac902733670a9"
  644. @pytest.mark.asyncio
  645. @pytest.mark.integration
  646. async def test_configure_pfus_takes_priority_over_slot(self, async_client: AsyncClient, printer_factory):
  647. """Provided PFUS* preset takes priority over slot's existing preset."""
  648. printer = await printer_factory(name="H2D")
  649. mock_client = MagicMock()
  650. mock_client.ams_set_filament_setting.return_value = True
  651. mock_client.extrusion_cali_sel.return_value = True
  652. mock_client.request_status_update.return_value = True
  653. # Simulate slot already configured by slicer with cloud-synced preset
  654. mock_status = MagicMock()
  655. mock_status.raw_data = {
  656. "ams": {
  657. "ams": [
  658. {
  659. "id": 2,
  660. "tray": [
  661. {
  662. "id": 3,
  663. "tray_info_idx": "P4d64437",
  664. "tray_type": "PLA",
  665. "tray_color": "FF0000FF",
  666. }
  667. ],
  668. }
  669. ]
  670. }
  671. }
  672. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  673. mock_pm.get_client.return_value = mock_client
  674. mock_pm.get_status.return_value = mock_status
  675. response = await async_client.post(
  676. f"/api/v1/printers/{printer.id}/slots/2/3/configure",
  677. params={
  678. "tray_info_idx": "PFUS9ac902733670a9",
  679. "tray_type": "PLA",
  680. "tray_sub_brands": "Devil Design PLA",
  681. "tray_color": "FF0000FF",
  682. "nozzle_temp_min": 190,
  683. "nozzle_temp_max": 230,
  684. },
  685. )
  686. assert response.status_code == 200
  687. call_kwargs = mock_client.ams_set_filament_setting.call_args
  688. # Provided preset wins over slot's existing one
  689. assert call_kwargs.kwargs["tray_info_idx"] == "PFUS9ac902733670a9"
  690. @pytest.mark.asyncio
  691. @pytest.mark.integration
  692. async def test_configure_pfus_used_regardless_of_slot_material(self, async_client: AsyncClient, printer_factory):
  693. """Provided PFUS* preset is used even when slot has a different material."""
  694. printer = await printer_factory(name="H2D")
  695. mock_client = MagicMock()
  696. mock_client.ams_set_filament_setting.return_value = True
  697. mock_client.extrusion_cali_sel.return_value = True
  698. mock_client.request_status_update.return_value = True
  699. # Slot currently has PETG but user is configuring PLA
  700. mock_status = MagicMock()
  701. mock_status.raw_data = {
  702. "ams": {
  703. "ams": [
  704. {
  705. "id": 2,
  706. "tray": [{"id": 3, "tray_info_idx": "GFG99", "tray_type": "PETG", "tray_color": "FFFFFFFF"}],
  707. }
  708. ]
  709. }
  710. }
  711. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  712. mock_pm.get_client.return_value = mock_client
  713. mock_pm.get_status.return_value = mock_status
  714. response = await async_client.post(
  715. f"/api/v1/printers/{printer.id}/slots/2/3/configure",
  716. params={
  717. "tray_info_idx": "PFUS9ac902733670a9",
  718. "tray_type": "PLA",
  719. "tray_sub_brands": "Devil Design PLA",
  720. "tray_color": "FF0000FF",
  721. "nozzle_temp_min": 190,
  722. "nozzle_temp_max": 230,
  723. },
  724. )
  725. assert response.status_code == 200
  726. call_kwargs = mock_client.ams_set_filament_setting.call_args
  727. # Provided preset wins — slot's material is irrelevant
  728. assert call_kwargs.kwargs["tray_info_idx"] == "PFUS9ac902733670a9"
  729. @pytest.mark.asyncio
  730. @pytest.mark.integration
  731. async def test_configure_empty_id_uses_generic(self, async_client: AsyncClient, printer_factory):
  732. """Empty tray_info_idx (local preset) is replaced with generic."""
  733. printer = await printer_factory(name="H2D")
  734. mock_client = MagicMock()
  735. mock_client.ams_set_filament_setting.return_value = True
  736. mock_client.extrusion_cali_sel.return_value = True
  737. mock_client.request_status_update.return_value = True
  738. mock_status = MagicMock()
  739. mock_status.raw_data = {"ams": {"ams": []}}
  740. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  741. mock_pm.get_client.return_value = mock_client
  742. mock_pm.get_status.return_value = mock_status
  743. response = await async_client.post(
  744. f"/api/v1/printers/{printer.id}/slots/2/3/configure",
  745. params={
  746. "tray_info_idx": "",
  747. "tray_type": "PETG",
  748. "tray_sub_brands": "PETG Basic",
  749. "tray_color": "FFFFFFFF",
  750. "nozzle_temp_min": 220,
  751. "nozzle_temp_max": 260,
  752. },
  753. )
  754. assert response.status_code == 200
  755. call_kwargs = mock_client.ams_set_filament_setting.call_args
  756. assert call_kwargs.kwargs["tray_info_idx"] == "GFG99"
  757. @pytest.mark.asyncio
  758. @pytest.mark.integration
  759. async def test_configure_pfus_preserves_setting_id_pair(self, async_client: AsyncClient, printer_factory):
  760. """Both tray_info_idx=PFUS* and setting_id=PFUS* are forwarded untouched.
  761. Pins the end-to-end contract the frontend #1053 fix relies on: when the
  762. user configures a slot with a custom cloud preset whose cloud detail
  763. has filament_id=null, the frontend sends the setting_id in BOTH fields
  764. and the backend must not collapse either to a generic GF* ID.
  765. """
  766. printer = await printer_factory(name="H2D")
  767. mock_client = MagicMock()
  768. mock_client.ams_set_filament_setting.return_value = True
  769. mock_client.extrusion_cali_sel.return_value = True
  770. mock_client.request_status_update.return_value = True
  771. mock_status = MagicMock()
  772. mock_status.raw_data = {"ams": {"ams": []}}
  773. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  774. mock_pm.get_client.return_value = mock_client
  775. mock_pm.get_status.return_value = mock_status
  776. response = await async_client.post(
  777. f"/api/v1/printers/{printer.id}/slots/128/0/configure",
  778. params={
  779. "tray_info_idx": "PFUSa8fb76f9733e3c",
  780. "tray_type": "ABS",
  781. "tray_sub_brands": "Sting3D ABS",
  782. "tray_color": "000000FF",
  783. "nozzle_temp_min": 240,
  784. "nozzle_temp_max": 280,
  785. "setting_id": "PFUSa8fb76f9733e3c",
  786. },
  787. )
  788. assert response.status_code == 200
  789. call_kwargs = mock_client.ams_set_filament_setting.call_args
  790. assert call_kwargs.kwargs["tray_info_idx"] == "PFUSa8fb76f9733e3c"
  791. assert call_kwargs.kwargs["setting_id"] == "PFUSa8fb76f9733e3c"
  792. # Explicitly assert no generic-collapse happened for this HT slot.
  793. assert call_kwargs.kwargs["tray_info_idx"] != "GFB99"
  794. class TestSkipObjectsAPI:
  795. """Integration tests for skip objects endpoints."""
  796. # ========================================================================
  797. # Get printable objects endpoint
  798. # ========================================================================
  799. @pytest.mark.asyncio
  800. @pytest.mark.integration
  801. async def test_get_objects_not_found(self, async_client: AsyncClient):
  802. """Verify 404 for non-existent printer."""
  803. response = await async_client.get("/api/v1/printers/99999/print/objects")
  804. assert response.status_code == 404
  805. @pytest.mark.asyncio
  806. @pytest.mark.integration
  807. async def test_get_objects_not_connected(self, async_client: AsyncClient, printer_factory):
  808. """Verify error when printer is not connected."""
  809. printer = await printer_factory(name="Disconnected Printer")
  810. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  811. mock_pm.get_client.return_value = None
  812. response = await async_client.get(f"/api/v1/printers/{printer.id}/print/objects")
  813. assert response.status_code == 400
  814. assert "not connected" in response.json()["detail"].lower()
  815. @pytest.mark.asyncio
  816. @pytest.mark.integration
  817. async def test_get_objects_empty(self, async_client: AsyncClient, printer_factory):
  818. """Verify empty objects list when no print is active."""
  819. printer = await printer_factory(name="Idle Printer")
  820. mock_client = MagicMock()
  821. mock_client.state.printable_objects = {}
  822. mock_client.state.skipped_objects = []
  823. mock_client.state.state = "IDLE"
  824. mock_client.state.subtask_name = None # Prevent FTP download attempt
  825. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  826. mock_pm.get_client.return_value = mock_client
  827. response = await async_client.get(f"/api/v1/printers/{printer.id}/print/objects")
  828. assert response.status_code == 200
  829. result = response.json()
  830. assert result["objects"] == []
  831. assert result["total"] == 0
  832. assert result["skipped_count"] == 0
  833. assert result["is_printing"] is False
  834. @pytest.mark.asyncio
  835. @pytest.mark.integration
  836. async def test_get_objects_with_data(self, async_client: AsyncClient, printer_factory):
  837. """Verify objects list when print is active."""
  838. printer = await printer_factory(name="Printing Printer")
  839. mock_client = MagicMock()
  840. mock_client.state.printable_objects = {100: "Part A", 200: "Part B", 300: "Part C"}
  841. mock_client.state.skipped_objects = [200]
  842. mock_client.state.state = "RUNNING"
  843. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  844. mock_pm.get_client.return_value = mock_client
  845. response = await async_client.get(f"/api/v1/printers/{printer.id}/print/objects")
  846. assert response.status_code == 200
  847. result = response.json()
  848. assert result["total"] == 3
  849. assert result["skipped_count"] == 1
  850. assert result["is_printing"] is True
  851. # Check objects have correct structure
  852. objects_by_id = {obj["id"]: obj for obj in result["objects"]}
  853. assert objects_by_id[100]["name"] == "Part A"
  854. assert objects_by_id[100]["skipped"] is False
  855. assert objects_by_id[200]["name"] == "Part B"
  856. assert objects_by_id[200]["skipped"] is True
  857. assert objects_by_id[300]["name"] == "Part C"
  858. assert objects_by_id[300]["skipped"] is False
  859. # ========================================================================
  860. # Skip objects endpoint
  861. # ========================================================================
  862. @pytest.mark.asyncio
  863. @pytest.mark.integration
  864. async def test_get_objects_with_positions(self, async_client: AsyncClient, printer_factory):
  865. """Verify objects list includes position data when available."""
  866. printer = await printer_factory(name="Printing Printer")
  867. # New format with position data
  868. mock_client = MagicMock()
  869. mock_client.state.printable_objects = {
  870. 100: {"name": "Part A", "x": 50.0, "y": 100.0},
  871. 200: {"name": "Part B", "x": 150.0, "y": 100.0},
  872. }
  873. mock_client.state.skipped_objects = []
  874. mock_client.state.state = "RUNNING"
  875. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  876. mock_pm.get_client.return_value = mock_client
  877. response = await async_client.get(f"/api/v1/printers/{printer.id}/print/objects")
  878. assert response.status_code == 200
  879. result = response.json()
  880. assert result["total"] == 2
  881. # Check objects have position data
  882. objects_by_id = {obj["id"]: obj for obj in result["objects"]}
  883. assert objects_by_id[100]["name"] == "Part A"
  884. assert objects_by_id[100]["x"] == 50.0
  885. assert objects_by_id[100]["y"] == 100.0
  886. assert objects_by_id[200]["name"] == "Part B"
  887. assert objects_by_id[200]["x"] == 150.0
  888. assert objects_by_id[200]["y"] == 100.0
  889. # ========================================================================
  890. # Skip objects endpoint
  891. # ========================================================================
  892. @pytest.mark.asyncio
  893. @pytest.mark.integration
  894. async def test_skip_objects_not_found(self, async_client: AsyncClient):
  895. """Verify 404 for non-existent printer."""
  896. response = await async_client.post("/api/v1/printers/99999/print/skip-objects", json=[100])
  897. assert response.status_code == 404
  898. @pytest.mark.asyncio
  899. @pytest.mark.integration
  900. async def test_skip_objects_not_connected(self, async_client: AsyncClient, printer_factory):
  901. """Verify error when printer is not connected."""
  902. printer = await printer_factory(name="Disconnected Printer")
  903. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  904. mock_pm.get_client.return_value = None
  905. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/skip-objects", json=[100])
  906. assert response.status_code == 400
  907. assert "not connected" in response.json()["detail"].lower()
  908. @pytest.mark.asyncio
  909. @pytest.mark.integration
  910. async def test_skip_objects_empty_list(self, async_client: AsyncClient, printer_factory):
  911. """Verify error when no object IDs provided."""
  912. printer = await printer_factory(name="Printing Printer")
  913. mock_client = MagicMock()
  914. mock_client.state.printable_objects = {100: "Part A"}
  915. mock_client.state.skipped_objects = []
  916. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  917. mock_pm.get_client.return_value = mock_client
  918. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/skip-objects", json=[])
  919. assert response.status_code == 400
  920. assert "no object" in response.json()["detail"].lower()
  921. @pytest.mark.asyncio
  922. @pytest.mark.integration
  923. async def test_skip_objects_invalid_id(self, async_client: AsyncClient, printer_factory):
  924. """Verify error when object ID doesn't exist."""
  925. printer = await printer_factory(name="Printing Printer")
  926. mock_client = MagicMock()
  927. mock_client.state.printable_objects = {100: "Part A"}
  928. mock_client.state.skipped_objects = []
  929. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  930. mock_pm.get_client.return_value = mock_client
  931. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/skip-objects", json=[999])
  932. assert response.status_code == 400
  933. assert "invalid" in response.json()["detail"].lower()
  934. @pytest.mark.asyncio
  935. @pytest.mark.integration
  936. async def test_skip_objects_success(self, async_client: AsyncClient, printer_factory):
  937. """Verify successful skip objects request."""
  938. printer = await printer_factory(name="Printing Printer")
  939. mock_client = MagicMock()
  940. mock_client.state.printable_objects = {100: "Part A", 200: "Part B"}
  941. mock_client.state.skipped_objects = []
  942. mock_client.skip_objects.return_value = True
  943. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  944. mock_pm.get_client.return_value = mock_client
  945. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/skip-objects", json=[100])
  946. assert response.status_code == 200
  947. result = response.json()
  948. assert result["success"] is True
  949. assert 100 in result["skipped_objects"]
  950. mock_client.skip_objects.assert_called_once_with([100])
  951. @pytest.mark.asyncio
  952. @pytest.mark.integration
  953. async def test_skip_objects_multiple(self, async_client: AsyncClient, printer_factory):
  954. """Verify skipping multiple objects at once."""
  955. printer = await printer_factory(name="Printing Printer")
  956. mock_client = MagicMock()
  957. mock_client.state.printable_objects = {100: "Part A", 200: "Part B", 300: "Part C"}
  958. mock_client.state.skipped_objects = []
  959. mock_client.skip_objects.return_value = True
  960. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  961. mock_pm.get_client.return_value = mock_client
  962. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/skip-objects", json=[100, 200])
  963. assert response.status_code == 200
  964. result = response.json()
  965. assert result["success"] is True
  966. assert 100 in result["skipped_objects"]
  967. assert 200 in result["skipped_objects"]
  968. mock_client.skip_objects.assert_called_once_with([100, 200])
  969. class TestChamberLightAPI:
  970. """Integration tests for chamber light control endpoint."""
  971. @pytest.mark.asyncio
  972. @pytest.mark.integration
  973. async def test_chamber_light_not_found(self, async_client: AsyncClient):
  974. """Verify 404 for non-existent printer."""
  975. response = await async_client.post("/api/v1/printers/99999/chamber-light?on=true")
  976. assert response.status_code == 404
  977. @pytest.mark.asyncio
  978. @pytest.mark.integration
  979. async def test_chamber_light_not_connected(self, async_client: AsyncClient, printer_factory):
  980. """Verify error when printer is not connected."""
  981. printer = await printer_factory(name="Disconnected Printer")
  982. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  983. mock_pm.get_client.return_value = None
  984. response = await async_client.post(f"/api/v1/printers/{printer.id}/chamber-light?on=true")
  985. assert response.status_code == 400
  986. assert "not connected" in response.json()["detail"].lower()
  987. @pytest.mark.asyncio
  988. @pytest.mark.integration
  989. async def test_chamber_light_on_success(self, async_client: AsyncClient, printer_factory):
  990. """Verify successful chamber light on request."""
  991. printer = await printer_factory(name="Test Printer")
  992. mock_client = MagicMock()
  993. mock_client.set_chamber_light.return_value = True
  994. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  995. mock_pm.get_client.return_value = mock_client
  996. response = await async_client.post(f"/api/v1/printers/{printer.id}/chamber-light?on=true")
  997. assert response.status_code == 200
  998. result = response.json()
  999. assert result["success"] is True
  1000. assert "on" in result["message"].lower()
  1001. mock_client.set_chamber_light.assert_called_once_with(True)
  1002. @pytest.mark.asyncio
  1003. @pytest.mark.integration
  1004. async def test_chamber_light_off_success(self, async_client: AsyncClient, printer_factory):
  1005. """Verify successful chamber light off request."""
  1006. printer = await printer_factory(name="Test Printer")
  1007. mock_client = MagicMock()
  1008. mock_client.set_chamber_light.return_value = True
  1009. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1010. mock_pm.get_client.return_value = mock_client
  1011. response = await async_client.post(f"/api/v1/printers/{printer.id}/chamber-light?on=false")
  1012. assert response.status_code == 200
  1013. result = response.json()
  1014. assert result["success"] is True
  1015. assert "off" in result["message"].lower()
  1016. mock_client.set_chamber_light.assert_called_once_with(False)
  1017. @pytest.mark.asyncio
  1018. @pytest.mark.integration
  1019. async def test_chamber_light_failure(self, async_client: AsyncClient, printer_factory):
  1020. """Verify error handling when chamber light control fails."""
  1021. printer = await printer_factory(name="Test Printer")
  1022. mock_client = MagicMock()
  1023. mock_client.set_chamber_light.return_value = False
  1024. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1025. mock_pm.get_client.return_value = mock_client
  1026. response = await async_client.post(f"/api/v1/printers/{printer.id}/chamber-light?on=true")
  1027. assert response.status_code == 500
  1028. assert "failed" in response.json()["detail"].lower()
  1029. class TestAirductModeAPI:
  1030. """Integration tests for the airduct mode endpoint (P2S/H2*)."""
  1031. @pytest.mark.asyncio
  1032. @pytest.mark.integration
  1033. async def test_invalid_mode_rejected(self, async_client: AsyncClient, printer_factory):
  1034. printer = await printer_factory(name="P", model="P2S")
  1035. response = await async_client.post(f"/api/v1/printers/{printer.id}/airduct-mode?mode=foo")
  1036. assert response.status_code == 400
  1037. @pytest.mark.asyncio
  1038. @pytest.mark.integration
  1039. async def test_not_connected(self, async_client: AsyncClient, printer_factory):
  1040. printer = await printer_factory(name="P", model="P2S")
  1041. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1042. mock_pm.get_client.return_value = None
  1043. response = await async_client.post(f"/api/v1/printers/{printer.id}/airduct-mode?mode=cooling")
  1044. assert response.status_code == 400
  1045. @pytest.mark.asyncio
  1046. @pytest.mark.integration
  1047. async def test_cooling_success(self, async_client: AsyncClient, printer_factory):
  1048. printer = await printer_factory(name="P", model="P2S")
  1049. mock_client = MagicMock()
  1050. mock_client.set_airduct_mode.return_value = True
  1051. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1052. mock_pm.get_client.return_value = mock_client
  1053. response = await async_client.post(f"/api/v1/printers/{printer.id}/airduct-mode?mode=cooling")
  1054. assert response.status_code == 200
  1055. assert response.json()["success"] is True
  1056. mock_client.set_airduct_mode.assert_called_once_with("cooling")
  1057. @pytest.mark.asyncio
  1058. @pytest.mark.integration
  1059. async def test_heating_failure_returns_500(self, async_client: AsyncClient, printer_factory):
  1060. printer = await printer_factory(name="P", model="P2S")
  1061. mock_client = MagicMock()
  1062. mock_client.set_airduct_mode.return_value = False
  1063. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1064. mock_pm.get_client.return_value = mock_client
  1065. response = await async_client.post(f"/api/v1/printers/{printer.id}/airduct-mode?mode=heating")
  1066. assert response.status_code == 500
  1067. class TestClearHMSErrorsAPI:
  1068. """Integration tests for clear HMS errors endpoint."""
  1069. @pytest.mark.asyncio
  1070. @pytest.mark.integration
  1071. async def test_clear_hms_errors_not_found(self, async_client: AsyncClient):
  1072. """Verify 404 for non-existent printer."""
  1073. response = await async_client.post("/api/v1/printers/99999/hms/clear")
  1074. assert response.status_code == 404
  1075. @pytest.mark.asyncio
  1076. @pytest.mark.integration
  1077. async def test_clear_hms_errors_not_connected(self, async_client: AsyncClient, printer_factory):
  1078. """Verify error when printer is not connected."""
  1079. printer = await printer_factory(name="Disconnected Printer")
  1080. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1081. mock_pm.get_client.return_value = None
  1082. response = await async_client.post(f"/api/v1/printers/{printer.id}/hms/clear")
  1083. assert response.status_code == 400
  1084. assert "not connected" in response.json()["detail"].lower()
  1085. @pytest.mark.asyncio
  1086. @pytest.mark.integration
  1087. async def test_clear_hms_errors_success(self, async_client: AsyncClient, printer_factory):
  1088. """Verify successful clear HMS errors request."""
  1089. printer = await printer_factory(name="Test Printer")
  1090. mock_client = MagicMock()
  1091. mock_client.clear_hms_errors.return_value = True
  1092. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1093. mock_pm.get_client.return_value = mock_client
  1094. response = await async_client.post(f"/api/v1/printers/{printer.id}/hms/clear")
  1095. assert response.status_code == 200
  1096. result = response.json()
  1097. assert result["success"] is True
  1098. assert "cleared" in result["message"].lower()
  1099. mock_client.clear_hms_errors.assert_called_once()
  1100. @pytest.mark.asyncio
  1101. @pytest.mark.integration
  1102. async def test_clear_hms_errors_failure(self, async_client: AsyncClient, printer_factory):
  1103. """Verify error handling when clear HMS errors fails."""
  1104. printer = await printer_factory(name="Test Printer")
  1105. mock_client = MagicMock()
  1106. mock_client.clear_hms_errors.return_value = False
  1107. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1108. mock_pm.get_client.return_value = mock_client
  1109. response = await async_client.post(f"/api/v1/printers/{printer.id}/hms/clear")
  1110. assert response.status_code == 500
  1111. assert "failed" in response.json()["detail"].lower()