test_printers_api.py 115 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851
  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 AsyncMock, MagicMock, patch
  5. import pytest
  6. from httpx import AsyncClient
  7. from sqlalchemy import select
  8. class TestPrintersAPI:
  9. """Integration tests for /api/v1/printers/ endpoints."""
  10. # ========================================================================
  11. # List endpoints
  12. # ========================================================================
  13. @pytest.mark.asyncio
  14. @pytest.mark.integration
  15. async def test_list_printers_empty(self, async_client: AsyncClient):
  16. """Verify empty list is returned when no printers exist."""
  17. response = await async_client.get("/api/v1/printers/")
  18. assert response.status_code == 200
  19. assert response.json() == []
  20. @pytest.mark.asyncio
  21. @pytest.mark.integration
  22. async def test_list_printers_with_data(self, async_client: AsyncClient, printer_factory, db_session):
  23. """Verify list returns existing printers."""
  24. await printer_factory(name="Test Printer")
  25. response = await async_client.get("/api/v1/printers/")
  26. assert response.status_code == 200
  27. data = response.json()
  28. assert len(data) >= 1
  29. assert any(p["name"] == "Test Printer" for p in data)
  30. # ========================================================================
  31. # Create endpoints
  32. # ========================================================================
  33. @pytest.mark.asyncio
  34. @pytest.mark.integration
  35. async def test_create_printer(self, async_client: AsyncClient):
  36. """Verify printer can be created."""
  37. data = {
  38. "name": "New Printer",
  39. "serial_number": "00M09A111111111",
  40. "ip_address": "192.168.1.100",
  41. "access_code": "12345678",
  42. "is_active": True,
  43. "model": "X1C",
  44. }
  45. response = await async_client.post("/api/v1/printers/", json=data)
  46. assert response.status_code == 200
  47. result = response.json()
  48. assert result["name"] == "New Printer"
  49. assert result["serial_number"] == "00M09A111111111"
  50. assert result["model"] == "X1C"
  51. @pytest.mark.asyncio
  52. @pytest.mark.integration
  53. async def test_create_printer_with_hostname(self, async_client: AsyncClient):
  54. """Verify printer can be created with a hostname instead of IP address."""
  55. data = {
  56. "name": "DNS Printer",
  57. "serial_number": "00M09A555555555",
  58. "ip_address": "printer.local",
  59. "access_code": "12345678",
  60. "model": "P1S",
  61. }
  62. response = await async_client.post("/api/v1/printers/", json=data)
  63. assert response.status_code == 200
  64. result = response.json()
  65. assert result["name"] == "DNS Printer"
  66. assert result["ip_address"] == "printer.local"
  67. @pytest.mark.asyncio
  68. @pytest.mark.integration
  69. async def test_create_printer_with_fqdn(self, async_client: AsyncClient):
  70. """Verify printer can be created with a fully qualified domain name."""
  71. data = {
  72. "name": "FQDN Printer",
  73. "serial_number": "00M09A666666666",
  74. "ip_address": "my-printer.home.lan",
  75. "access_code": "12345678",
  76. "model": "X1C",
  77. }
  78. response = await async_client.post("/api/v1/printers/", json=data)
  79. assert response.status_code == 200
  80. result = response.json()
  81. assert result["ip_address"] == "my-printer.home.lan"
  82. @pytest.mark.asyncio
  83. @pytest.mark.integration
  84. async def test_create_printer_invalid_hostname(self, async_client: AsyncClient):
  85. """Verify invalid hostnames are rejected."""
  86. data = {
  87. "name": "Bad Printer",
  88. "serial_number": "00M09A777777777",
  89. "ip_address": "-invalid",
  90. "access_code": "12345678",
  91. }
  92. response = await async_client.post("/api/v1/printers/", json=data)
  93. assert response.status_code == 422
  94. @pytest.mark.asyncio
  95. @pytest.mark.integration
  96. async def test_create_printer_duplicate_serial(self, async_client: AsyncClient, printer_factory, db_session):
  97. """Verify duplicate serial number is rejected."""
  98. await printer_factory(serial_number="00M09A222222222")
  99. data = {
  100. "name": "Duplicate Printer",
  101. "serial_number": "00M09A222222222",
  102. "ip_address": "192.168.1.101",
  103. "access_code": "12345678",
  104. }
  105. response = await async_client.post("/api/v1/printers/", json=data)
  106. # Should fail due to duplicate serial
  107. assert response.status_code in [400, 409, 422, 500]
  108. # ========================================================================
  109. # Get single endpoint
  110. # ========================================================================
  111. @pytest.mark.asyncio
  112. @pytest.mark.integration
  113. async def test_get_printer(self, async_client: AsyncClient, printer_factory, db_session):
  114. """Verify single printer can be retrieved."""
  115. printer = await printer_factory(name="Get Test Printer")
  116. response = await async_client.get(f"/api/v1/printers/{printer.id}")
  117. assert response.status_code == 200
  118. result = response.json()
  119. assert result["id"] == printer.id
  120. assert result["name"] == "Get Test Printer"
  121. @pytest.mark.asyncio
  122. @pytest.mark.integration
  123. async def test_get_printer_not_found(self, async_client: AsyncClient):
  124. """Verify 404 for non-existent printer."""
  125. response = await async_client.get("/api/v1/printers/9999")
  126. assert response.status_code == 404
  127. # ========================================================================
  128. # Update endpoints
  129. # ========================================================================
  130. @pytest.mark.asyncio
  131. @pytest.mark.integration
  132. async def test_update_printer_name(self, async_client: AsyncClient, printer_factory, db_session):
  133. """Verify printer name can be updated."""
  134. printer = await printer_factory(name="Original Name")
  135. response = await async_client.patch(f"/api/v1/printers/{printer.id}", json={"name": "Updated Name"})
  136. assert response.status_code == 200
  137. assert response.json()["name"] == "Updated Name"
  138. @pytest.mark.asyncio
  139. @pytest.mark.integration
  140. async def test_update_printer_active_status(self, async_client: AsyncClient, printer_factory, db_session):
  141. """Verify printer active status can be updated."""
  142. printer = await printer_factory(is_active=True)
  143. response = await async_client.patch(f"/api/v1/printers/{printer.id}", json={"is_active": False})
  144. assert response.status_code == 200
  145. assert response.json()["is_active"] is False
  146. @pytest.mark.asyncio
  147. @pytest.mark.integration
  148. async def test_update_printer_auto_archive(self, async_client: AsyncClient, printer_factory, db_session):
  149. """Verify auto_archive setting can be updated."""
  150. printer = await printer_factory(auto_archive=True)
  151. response = await async_client.patch(f"/api/v1/printers/{printer.id}", json={"auto_archive": False})
  152. assert response.status_code == 200
  153. assert response.json()["auto_archive"] is False
  154. @pytest.mark.asyncio
  155. @pytest.mark.integration
  156. async def test_update_nonexistent_printer(self, async_client: AsyncClient):
  157. """Verify updating non-existent printer returns 404."""
  158. response = await async_client.patch("/api/v1/printers/9999", json={"name": "New Name"})
  159. assert response.status_code == 404
  160. # ========================================================================
  161. # Delete endpoints
  162. # ========================================================================
  163. @pytest.mark.asyncio
  164. @pytest.mark.integration
  165. async def test_delete_printer(self, async_client: AsyncClient, printer_factory, db_session):
  166. """Verify printer can be deleted."""
  167. printer = await printer_factory()
  168. printer_id = printer.id
  169. response = await async_client.delete(f"/api/v1/printers/{printer_id}")
  170. assert response.status_code == 200
  171. # Verify deleted
  172. response = await async_client.get(f"/api/v1/printers/{printer_id}")
  173. assert response.status_code == 404
  174. @pytest.mark.asyncio
  175. @pytest.mark.integration
  176. async def test_delete_nonexistent_printer(self, async_client: AsyncClient):
  177. """Verify deleting non-existent printer returns 404."""
  178. response = await async_client.delete("/api/v1/printers/9999")
  179. assert response.status_code == 404
  180. # ========================================================================
  181. # Status endpoint
  182. # ========================================================================
  183. @pytest.mark.asyncio
  184. @pytest.mark.integration
  185. async def test_get_printer_status(
  186. self, async_client: AsyncClient, printer_factory, mock_printer_manager, db_session
  187. ):
  188. """Verify printer status can be retrieved."""
  189. printer = await printer_factory()
  190. response = await async_client.get(f"/api/v1/printers/{printer.id}/status")
  191. assert response.status_code == 200
  192. result = response.json()
  193. assert "connected" in result
  194. assert "state" in result
  195. @pytest.mark.asyncio
  196. @pytest.mark.integration
  197. async def test_get_printer_status_not_found(self, async_client: AsyncClient):
  198. """Verify 404 for status of non-existent printer."""
  199. response = await async_client.get("/api/v1/printers/9999/status")
  200. assert response.status_code == 404
  201. @pytest.mark.asyncio
  202. @pytest.mark.integration
  203. async def test_get_printer_status_includes_fila_switch_when_installed(
  204. self, async_client: AsyncClient, printer_factory, db_session
  205. ):
  206. """When the FTS accessory is installed, the status response must include
  207. the fila_switch object with the routing arrays. See #1162.
  208. The accessory is detected from print.device.fila_switch in MQTT;
  209. we feed a PrinterState with FilaSwitchState(installed=True, ...) and
  210. confirm it survives the schema serialization round-trip.
  211. """
  212. from unittest.mock import MagicMock, patch
  213. from backend.app.services.bambu_mqtt import FilaSwitchState, PrinterState
  214. printer = await printer_factory()
  215. state = PrinterState()
  216. state.connected = True
  217. state.state = "IDLE"
  218. state.fila_switch = FilaSwitchState(
  219. installed=True,
  220. in_slots=[-1, 2],
  221. out_extruders=[0, 1],
  222. stat=0,
  223. info=2,
  224. )
  225. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  226. mock_pm.get_status = MagicMock(return_value=state)
  227. mock_pm.is_awaiting_plate_clear = MagicMock(return_value=False)
  228. response = await async_client.get(f"/api/v1/printers/{printer.id}/status")
  229. assert response.status_code == 200
  230. result = response.json()
  231. assert result["fila_switch"] is not None
  232. assert result["fila_switch"]["installed"] is True
  233. assert result["fila_switch"]["in_slots"] == [-1, 2]
  234. assert result["fila_switch"]["out_extruders"] == [0, 1]
  235. assert result["fila_switch"]["stat"] == 0
  236. assert result["fila_switch"]["info"] == 2
  237. @pytest.mark.asyncio
  238. @pytest.mark.integration
  239. async def test_cover_uses_dispatched_plate_when_gcode_file_lacks_path(
  240. self, async_client: AsyncClient, printer_factory, db_session, tmp_path
  241. ):
  242. """When firmware drops the plate path from gcode_file (e.g. P1S
  243. 01.10.00.00, #1166), the dispatched-plate record must take precedence
  244. and serve plate 4's thumbnail instead of falling back to plate_1.png."""
  245. import io
  246. import zipfile
  247. from unittest.mock import MagicMock, patch
  248. from backend.app.services.bambu_ftp import cache_3mf_download
  249. from backend.app.services.bambu_mqtt import PrinterState
  250. printer = await printer_factory()
  251. # Build a 3MF that mimics a "true" multi-plate archive: thumbnails
  252. # for plates 1..4 are all present, gcode files for plates 1..4 are
  253. # all present. Without the dispatch record we'd default to plate_1.png.
  254. threemf_path = tmp_path / "MyModel.3mf"
  255. with zipfile.ZipFile(threemf_path, "w") as zf:
  256. for plate in range(1, 5):
  257. zf.writestr(f"Metadata/plate_{plate}.png", f"PLATE_{plate}_PNG".encode())
  258. zf.writestr(f"Metadata/plate_{plate}.gcode", f"; plate {plate} gcode\n")
  259. cache_3mf_download(printer.id, "MyModel.3mf", threemf_path)
  260. state = PrinterState()
  261. state.connected = True
  262. state.state = "RUNNING"
  263. state.subtask_name = "MyModel"
  264. state.gcode_file = "MyModel.3mf" # firmware drops plate path
  265. state.dispatched_plate_id = 4
  266. state.dispatched_subtask = "MyModel"
  267. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  268. mock_pm.get_status = MagicMock(return_value=state)
  269. mock_pm.is_awaiting_plate_clear = MagicMock(return_value=False)
  270. response = await async_client.get(f"/api/v1/printers/{printer.id}/cover")
  271. assert response.status_code == 200
  272. assert response.content == b"PLATE_4_PNG"
  273. @pytest.mark.asyncio
  274. @pytest.mark.integration
  275. async def test_cover_3mf_scan_fallback_for_per_plate_archive(
  276. self, async_client: AsyncClient, printer_factory, db_session, tmp_path
  277. ):
  278. """Per-plate archives sliced separately in Bambu Studio contain a
  279. single Metadata/plate_N.gcode (the active plate) but bundle thumbnails
  280. for every plate. With no dispatch record (e.g. dispatched via Studio
  281. directly) and no plate path in gcode_file, the route must scan the
  282. 3MF and pick plate N's thumbnail. See #1166 option 4."""
  283. import zipfile
  284. from unittest.mock import MagicMock, patch
  285. from backend.app.services.bambu_ftp import cache_3mf_download
  286. from backend.app.services.bambu_mqtt import PrinterState
  287. printer = await printer_factory()
  288. # Per-plate archive: thumbnails for all plates, gcode for plate 3 only.
  289. threemf_path = tmp_path / "PerPlate.3mf"
  290. with zipfile.ZipFile(threemf_path, "w") as zf:
  291. for plate in range(1, 5):
  292. zf.writestr(f"Metadata/plate_{plate}.png", f"PLATE_{plate}_PNG".encode())
  293. zf.writestr("Metadata/plate_3.gcode", "; only plate 3 has gcode\n")
  294. cache_3mf_download(printer.id, "PerPlate.3mf", threemf_path)
  295. state = PrinterState()
  296. state.connected = True
  297. state.state = "RUNNING"
  298. state.subtask_name = "PerPlate"
  299. state.gcode_file = "PerPlate.3mf"
  300. # No dispatch record (Studio-direct dispatch).
  301. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  302. mock_pm.get_status = MagicMock(return_value=state)
  303. mock_pm.is_awaiting_plate_clear = MagicMock(return_value=False)
  304. response = await async_client.get(f"/api/v1/printers/{printer.id}/cover")
  305. assert response.status_code == 200
  306. assert response.content == b"PLATE_3_PNG"
  307. @pytest.mark.asyncio
  308. @pytest.mark.integration
  309. async def test_get_printer_status_omits_fila_switch_when_not_installed(
  310. self, async_client: AsyncClient, printer_factory, db_session
  311. ):
  312. """Without the FTS accessory, fila_switch must be null so the frontend
  313. keeps applying the per-extruder filter on regular dual-nozzle printers."""
  314. from unittest.mock import MagicMock, patch
  315. from backend.app.services.bambu_mqtt import PrinterState
  316. printer = await printer_factory()
  317. state = PrinterState()
  318. state.connected = True
  319. state.state = "IDLE"
  320. # default fila_switch — installed = False
  321. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  322. mock_pm.get_status = MagicMock(return_value=state)
  323. mock_pm.is_awaiting_plate_clear = MagicMock(return_value=False)
  324. response = await async_client.get(f"/api/v1/printers/{printer.id}/status")
  325. assert response.status_code == 200
  326. result = response.json()
  327. assert result["fila_switch"] is None
  328. # ========================================================================
  329. # Test connection endpoint
  330. # ========================================================================
  331. class TestPrinterDataIntegrity:
  332. """Tests for printer data integrity."""
  333. @pytest.mark.asyncio
  334. @pytest.mark.integration
  335. async def test_printer_stores_all_fields(self, async_client: AsyncClient, printer_factory, db_session):
  336. """Verify printer stores all fields correctly."""
  337. printer = await printer_factory(
  338. name="Full Test Printer",
  339. serial_number="00M09A444444444",
  340. ip_address="192.168.1.150",
  341. model="P1S",
  342. is_active=True,
  343. auto_archive=False,
  344. )
  345. response = await async_client.get(f"/api/v1/printers/{printer.id}")
  346. assert response.status_code == 200
  347. result = response.json()
  348. assert result["name"] == "Full Test Printer"
  349. assert result["serial_number"] == "00M09A444444444"
  350. assert result["ip_address"] == "192.168.1.150"
  351. assert result["model"] == "P1S"
  352. assert result["is_active"] is True
  353. assert result["auto_archive"] is False
  354. @pytest.mark.asyncio
  355. @pytest.mark.integration
  356. async def test_printer_update_persists(self, async_client: AsyncClient, printer_factory, db_session):
  357. """CRITICAL: Verify printer updates persist."""
  358. printer = await printer_factory(name="Original", is_active=True)
  359. # Update
  360. await async_client.patch(f"/api/v1/printers/{printer.id}", json={"name": "Updated", "is_active": False})
  361. # Verify persistence
  362. response = await async_client.get(f"/api/v1/printers/{printer.id}")
  363. result = response.json()
  364. assert result["name"] == "Updated"
  365. assert result["is_active"] is False
  366. # ========================================================================
  367. # Refresh status endpoint
  368. # ========================================================================
  369. @pytest.mark.asyncio
  370. @pytest.mark.integration
  371. async def test_refresh_status_not_found(self, async_client: AsyncClient):
  372. """Verify 404 for non-existent printer."""
  373. response = await async_client.post("/api/v1/printers/99999/refresh-status")
  374. assert response.status_code == 404
  375. @pytest.mark.asyncio
  376. @pytest.mark.integration
  377. async def test_refresh_status_not_connected(self, async_client: AsyncClient, printer_factory):
  378. """Verify 400 when printer is not connected."""
  379. printer = await printer_factory(name="Disconnected Printer")
  380. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  381. mock_pm.request_status_update.return_value = False
  382. response = await async_client.post(f"/api/v1/printers/{printer.id}/refresh-status")
  383. assert response.status_code == 400
  384. assert "not connected" in response.json()["detail"].lower()
  385. @pytest.mark.asyncio
  386. @pytest.mark.integration
  387. async def test_refresh_status_success(self, async_client: AsyncClient, printer_factory):
  388. """Verify successful refresh request."""
  389. printer = await printer_factory(name="Connected Printer")
  390. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  391. mock_pm.request_status_update.return_value = True
  392. response = await async_client.post(f"/api/v1/printers/{printer.id}/refresh-status")
  393. assert response.status_code == 200
  394. assert response.json()["status"] == "refresh_requested"
  395. mock_pm.request_status_update.assert_called_once_with(printer.id)
  396. # ========================================================================
  397. # Current print user endpoint (Issue #206)
  398. # ========================================================================
  399. @pytest.mark.asyncio
  400. @pytest.mark.integration
  401. async def test_get_current_print_user_not_found(self, async_client: AsyncClient):
  402. """Verify 404 for non-existent printer."""
  403. response = await async_client.get("/api/v1/printers/99999/current-print-user")
  404. assert response.status_code == 404
  405. @pytest.mark.asyncio
  406. @pytest.mark.integration
  407. async def test_get_current_print_user_returns_empty_when_no_user(self, async_client: AsyncClient, printer_factory):
  408. """Verify empty object returned when no user is tracked."""
  409. printer = await printer_factory(name="Test Printer")
  410. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  411. mock_pm.get_current_print_user.return_value = None
  412. response = await async_client.get(f"/api/v1/printers/{printer.id}/current-print-user")
  413. assert response.status_code == 200
  414. assert response.json() == {}
  415. @pytest.mark.asyncio
  416. @pytest.mark.integration
  417. async def test_get_current_print_user_returns_user_info(self, async_client: AsyncClient, printer_factory):
  418. """Verify user info is returned when tracked."""
  419. printer = await printer_factory(name="Test Printer")
  420. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  421. mock_pm.get_current_print_user.return_value = {"user_id": 42, "username": "testuser"}
  422. response = await async_client.get(f"/api/v1/printers/{printer.id}/current-print-user")
  423. assert response.status_code == 200
  424. result = response.json()
  425. assert result["user_id"] == 42
  426. assert result["username"] == "testuser"
  427. class TestPrintControlAPI:
  428. """Integration tests for print control endpoints (stop, pause, resume)."""
  429. # ========================================================================
  430. # Stop print endpoint
  431. # ========================================================================
  432. @pytest.mark.asyncio
  433. @pytest.mark.integration
  434. async def test_stop_print_not_found(self, async_client: AsyncClient):
  435. """Verify 404 for non-existent printer."""
  436. response = await async_client.post("/api/v1/printers/99999/print/stop")
  437. assert response.status_code == 404
  438. @pytest.mark.asyncio
  439. @pytest.mark.integration
  440. async def test_stop_print_not_connected(self, async_client: AsyncClient, printer_factory):
  441. """Verify error when printer is not connected."""
  442. printer = await printer_factory(name="Disconnected Printer")
  443. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  444. mock_pm.get_client.return_value = None
  445. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/stop")
  446. assert response.status_code == 400
  447. assert "not connected" in response.json()["detail"].lower()
  448. @pytest.mark.asyncio
  449. @pytest.mark.integration
  450. async def test_stop_print_success(self, async_client: AsyncClient, printer_factory):
  451. """Verify successful stop print request."""
  452. printer = await printer_factory(name="Printing Printer")
  453. mock_client = MagicMock()
  454. mock_client.stop_print.return_value = True
  455. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  456. mock_pm.get_client.return_value = mock_client
  457. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/stop")
  458. assert response.status_code == 200
  459. assert response.json()["success"] is True
  460. mock_client.stop_print.assert_called_once()
  461. # ========================================================================
  462. # Pause print endpoint
  463. # ========================================================================
  464. @pytest.mark.asyncio
  465. @pytest.mark.integration
  466. async def test_pause_print_not_found(self, async_client: AsyncClient):
  467. """Verify 404 for non-existent printer."""
  468. response = await async_client.post("/api/v1/printers/99999/print/pause")
  469. assert response.status_code == 404
  470. @pytest.mark.asyncio
  471. @pytest.mark.integration
  472. async def test_pause_print_not_connected(self, async_client: AsyncClient, printer_factory):
  473. """Verify error when printer is not connected."""
  474. printer = await printer_factory(name="Disconnected Printer")
  475. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  476. mock_pm.get_client.return_value = None
  477. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/pause")
  478. assert response.status_code == 400
  479. assert "not connected" in response.json()["detail"].lower()
  480. @pytest.mark.asyncio
  481. @pytest.mark.integration
  482. async def test_pause_print_success(self, async_client: AsyncClient, printer_factory):
  483. """Verify successful pause print request."""
  484. printer = await printer_factory(name="Printing Printer")
  485. mock_client = MagicMock()
  486. mock_client.pause_print.return_value = True
  487. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  488. mock_pm.get_client.return_value = mock_client
  489. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/pause")
  490. assert response.status_code == 200
  491. assert response.json()["success"] is True
  492. mock_client.pause_print.assert_called_once()
  493. # ========================================================================
  494. # Resume print endpoint
  495. # ========================================================================
  496. @pytest.mark.asyncio
  497. @pytest.mark.integration
  498. async def test_resume_print_not_found(self, async_client: AsyncClient):
  499. """Verify 404 for non-existent printer."""
  500. response = await async_client.post("/api/v1/printers/99999/print/resume")
  501. assert response.status_code == 404
  502. @pytest.mark.asyncio
  503. @pytest.mark.integration
  504. async def test_resume_print_not_connected(self, async_client: AsyncClient, printer_factory):
  505. """Verify error when printer is not connected."""
  506. printer = await printer_factory(name="Disconnected Printer")
  507. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  508. mock_pm.get_client.return_value = None
  509. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/resume")
  510. assert response.status_code == 400
  511. assert "not connected" in response.json()["detail"].lower()
  512. @pytest.mark.asyncio
  513. @pytest.mark.integration
  514. async def test_resume_print_success(self, async_client: AsyncClient, printer_factory):
  515. """Verify successful resume print request."""
  516. printer = await printer_factory(name="Paused Printer")
  517. mock_client = MagicMock()
  518. mock_client.resume_print.return_value = True
  519. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  520. mock_pm.get_client.return_value = mock_client
  521. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/resume")
  522. assert response.status_code == 200
  523. assert response.json()["success"] is True
  524. mock_client.resume_print.assert_called_once()
  525. class TestAMSRefreshAPI:
  526. """Integration tests for AMS slot refresh endpoint."""
  527. @pytest.mark.asyncio
  528. @pytest.mark.integration
  529. async def test_ams_refresh_not_found(self, async_client: AsyncClient):
  530. """Verify 404 for non-existent printer."""
  531. response = await async_client.post("/api/v1/printers/99999/ams/0/slot/0/refresh")
  532. assert response.status_code == 404
  533. @pytest.mark.asyncio
  534. @pytest.mark.integration
  535. async def test_ams_refresh_not_connected(self, async_client: AsyncClient, printer_factory):
  536. """Verify error when printer is not connected."""
  537. printer = await printer_factory(name="Disconnected Printer")
  538. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  539. mock_pm.get_client.return_value = None
  540. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/0/slot/0/refresh")
  541. assert response.status_code == 400
  542. assert "not connected" in response.json()["detail"].lower()
  543. @pytest.mark.asyncio
  544. @pytest.mark.integration
  545. async def test_ams_refresh_success(self, async_client: AsyncClient, printer_factory):
  546. """Verify successful AMS refresh request."""
  547. printer = await printer_factory(name="Printer with AMS")
  548. mock_client = MagicMock()
  549. mock_client.ams_refresh_tray.return_value = (True, "Refreshing AMS 0 tray 1")
  550. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  551. mock_pm.get_client.return_value = mock_client
  552. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/0/slot/1/refresh")
  553. assert response.status_code == 200
  554. result = response.json()
  555. assert result["success"] is True
  556. mock_client.ams_refresh_tray.assert_called_once_with(0, 1)
  557. @pytest.mark.asyncio
  558. @pytest.mark.integration
  559. async def test_ams_refresh_filament_loaded(self, async_client: AsyncClient, printer_factory):
  560. """Verify error when filament is loaded (can't refresh while loaded)."""
  561. printer = await printer_factory(name="Printer with AMS")
  562. mock_client = MagicMock()
  563. mock_client.ams_refresh_tray.return_value = (False, "Please unload filament first")
  564. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  565. mock_pm.get_client.return_value = mock_client
  566. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/0/slot/0/refresh")
  567. assert response.status_code == 400
  568. assert "unload" in response.json()["detail"].lower()
  569. class TestAMSLoadUnloadAPI:
  570. """Integration tests for AMS load / unload endpoints (#891)."""
  571. # ── load ─────────────────────────────────────────────────────────────────
  572. @pytest.mark.asyncio
  573. @pytest.mark.integration
  574. async def test_load_invalid_tray_id(self, async_client: AsyncClient, printer_factory):
  575. """tray_id outside {0..15, 254, 255} is rejected."""
  576. printer = await printer_factory(name="P")
  577. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/load?tray_id=99")
  578. assert response.status_code == 400
  579. @pytest.mark.asyncio
  580. @pytest.mark.integration
  581. async def test_load_not_found(self, async_client: AsyncClient):
  582. response = await async_client.post("/api/v1/printers/99999/ams/load?tray_id=0")
  583. assert response.status_code == 404
  584. @pytest.mark.asyncio
  585. @pytest.mark.integration
  586. async def test_load_not_connected(self, async_client: AsyncClient, printer_factory):
  587. printer = await printer_factory(name="Disconnected")
  588. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  589. mock_pm.get_client.return_value = None
  590. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/load?tray_id=0")
  591. assert response.status_code == 400
  592. assert "not connected" in response.json()["detail"].lower()
  593. @pytest.mark.asyncio
  594. @pytest.mark.integration
  595. async def test_load_ams_slot_success(self, async_client: AsyncClient, printer_factory):
  596. """tray_id=5 → AMS 1 slot 2 (1-indexed in the message)."""
  597. printer = await printer_factory(name="P")
  598. mock_client = MagicMock()
  599. mock_client.ams_load_filament.return_value = True
  600. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  601. mock_pm.get_client.return_value = mock_client
  602. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/load?tray_id=5")
  603. assert response.status_code == 200
  604. mock_client.ams_load_filament.assert_called_once_with(5)
  605. assert "AMS 1" in response.json()["message"]
  606. @pytest.mark.asyncio
  607. @pytest.mark.integration
  608. async def test_load_external_left_success(self, async_client: AsyncClient, printer_factory):
  609. """tray_id=254 → external spool / Ext-L."""
  610. printer = await printer_factory(name="P")
  611. mock_client = MagicMock()
  612. mock_client.ams_load_filament.return_value = True
  613. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  614. mock_pm.get_client.return_value = mock_client
  615. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/load?tray_id=254")
  616. assert response.status_code == 200
  617. mock_client.ams_load_filament.assert_called_once_with(254)
  618. assert "external" in response.json()["message"].lower()
  619. @pytest.mark.asyncio
  620. @pytest.mark.integration
  621. async def test_load_external_right_success(self, async_client: AsyncClient, printer_factory):
  622. """tray_id=255 → Ext-R on dual-nozzle H2D."""
  623. printer = await printer_factory(name="H2D")
  624. mock_client = MagicMock()
  625. mock_client.ams_load_filament.return_value = True
  626. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  627. mock_pm.get_client.return_value = mock_client
  628. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/load?tray_id=255")
  629. assert response.status_code == 200
  630. mock_client.ams_load_filament.assert_called_once_with(255)
  631. assert "Ext-R" in response.json()["message"]
  632. @pytest.mark.asyncio
  633. @pytest.mark.integration
  634. async def test_load_mqtt_failure_returns_500(self, async_client: AsyncClient, printer_factory):
  635. printer = await printer_factory(name="P")
  636. mock_client = MagicMock()
  637. mock_client.ams_load_filament.return_value = False
  638. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  639. mock_pm.get_client.return_value = mock_client
  640. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/load?tray_id=0")
  641. assert response.status_code == 500
  642. assert "failed" in response.json()["detail"].lower()
  643. # ── unload ───────────────────────────────────────────────────────────────
  644. @pytest.mark.asyncio
  645. @pytest.mark.integration
  646. async def test_unload_not_found(self, async_client: AsyncClient):
  647. response = await async_client.post("/api/v1/printers/99999/ams/unload")
  648. assert response.status_code == 404
  649. @pytest.mark.asyncio
  650. @pytest.mark.integration
  651. async def test_unload_not_connected(self, async_client: AsyncClient, printer_factory):
  652. printer = await printer_factory(name="Disconnected")
  653. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  654. mock_pm.get_client.return_value = None
  655. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/unload")
  656. assert response.status_code == 400
  657. assert "not connected" in response.json()["detail"].lower()
  658. @pytest.mark.asyncio
  659. @pytest.mark.integration
  660. async def test_unload_success(self, async_client: AsyncClient, printer_factory):
  661. printer = await printer_factory(name="P")
  662. mock_client = MagicMock()
  663. mock_client.ams_unload_filament.return_value = True
  664. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  665. mock_pm.get_client.return_value = mock_client
  666. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/unload")
  667. assert response.status_code == 200
  668. mock_client.ams_unload_filament.assert_called_once_with()
  669. assert response.json()["success"] is True
  670. @pytest.mark.asyncio
  671. @pytest.mark.integration
  672. async def test_unload_mqtt_failure_returns_500(self, async_client: AsyncClient, printer_factory):
  673. printer = await printer_factory(name="P")
  674. mock_client = MagicMock()
  675. mock_client.ams_unload_filament.return_value = False
  676. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  677. mock_pm.get_client.return_value = mock_client
  678. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/unload")
  679. assert response.status_code == 500
  680. assert "failed" in response.json()["detail"].lower()
  681. class TestConfigureAMSSlotAPI:
  682. """Integration tests for AMS slot configure endpoint — tray_info_idx resolution."""
  683. @pytest.mark.asyncio
  684. @pytest.mark.integration
  685. async def test_configure_not_connected(self, async_client: AsyncClient, printer_factory):
  686. """Verify error when printer is not connected."""
  687. printer = await printer_factory(name="Disconnected")
  688. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  689. mock_pm.get_client.return_value = None
  690. response = await async_client.post(
  691. f"/api/v1/printers/{printer.id}/slots/0/0/configure",
  692. params={
  693. "tray_info_idx": "GFL99",
  694. "tray_type": "PLA",
  695. "tray_sub_brands": "PLA Basic",
  696. "tray_color": "FF0000FF",
  697. "nozzle_temp_min": 190,
  698. "nozzle_temp_max": 230,
  699. },
  700. )
  701. assert response.status_code == 400
  702. assert "not connected" in response.json()["detail"].lower()
  703. @pytest.mark.asyncio
  704. @pytest.mark.integration
  705. async def test_configure_with_gf_id_keeps_it(self, async_client: AsyncClient, printer_factory):
  706. """Standard Bambu GF* filament IDs are sent as-is."""
  707. printer = await printer_factory(name="H2D")
  708. mock_client = MagicMock()
  709. mock_client.ams_set_filament_setting.return_value = True
  710. mock_client.extrusion_cali_sel.return_value = True
  711. mock_client.request_status_update.return_value = True
  712. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  713. mock_pm.get_client.return_value = mock_client
  714. mock_pm.get_status.return_value = None # No existing state
  715. response = await async_client.post(
  716. f"/api/v1/printers/{printer.id}/slots/2/3/configure",
  717. params={
  718. "tray_info_idx": "GFL05",
  719. "tray_type": "PLA",
  720. "tray_sub_brands": "PLA Basic",
  721. "tray_color": "FFFFFFFF",
  722. "nozzle_temp_min": 190,
  723. "nozzle_temp_max": 230,
  724. },
  725. )
  726. assert response.status_code == 200
  727. call_kwargs = mock_client.ams_set_filament_setting.call_args
  728. assert call_kwargs.kwargs["tray_info_idx"] == "GFL05"
  729. @pytest.mark.asyncio
  730. @pytest.mark.integration
  731. async def test_configure_pfus_sent_directly(self, async_client: AsyncClient, printer_factory):
  732. """PFUS* cloud-synced custom preset IDs are sent to the printer."""
  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": []}} # No existing tray data
  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": "PFUS9ac902733670a9",
  747. "tray_type": "PLA",
  748. "tray_sub_brands": "Devil Design PLA",
  749. "tray_color": "FF0000FF",
  750. "nozzle_temp_min": 190,
  751. "nozzle_temp_max": 230,
  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"] == "PFUS9ac902733670a9"
  757. @pytest.mark.asyncio
  758. @pytest.mark.integration
  759. async def test_configure_pfus_takes_priority_over_slot(self, async_client: AsyncClient, printer_factory):
  760. """Provided PFUS* preset takes priority over slot's existing preset."""
  761. printer = await printer_factory(name="H2D")
  762. mock_client = MagicMock()
  763. mock_client.ams_set_filament_setting.return_value = True
  764. mock_client.extrusion_cali_sel.return_value = True
  765. mock_client.request_status_update.return_value = True
  766. # Simulate slot already configured by slicer with cloud-synced preset
  767. mock_status = MagicMock()
  768. mock_status.raw_data = {
  769. "ams": {
  770. "ams": [
  771. {
  772. "id": 2,
  773. "tray": [
  774. {
  775. "id": 3,
  776. "tray_info_idx": "P4d64437",
  777. "tray_type": "PLA",
  778. "tray_color": "FF0000FF",
  779. }
  780. ],
  781. }
  782. ]
  783. }
  784. }
  785. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  786. mock_pm.get_client.return_value = mock_client
  787. mock_pm.get_status.return_value = mock_status
  788. response = await async_client.post(
  789. f"/api/v1/printers/{printer.id}/slots/2/3/configure",
  790. params={
  791. "tray_info_idx": "PFUS9ac902733670a9",
  792. "tray_type": "PLA",
  793. "tray_sub_brands": "Devil Design PLA",
  794. "tray_color": "FF0000FF",
  795. "nozzle_temp_min": 190,
  796. "nozzle_temp_max": 230,
  797. },
  798. )
  799. assert response.status_code == 200
  800. call_kwargs = mock_client.ams_set_filament_setting.call_args
  801. # Provided preset wins over slot's existing one
  802. assert call_kwargs.kwargs["tray_info_idx"] == "PFUS9ac902733670a9"
  803. @pytest.mark.asyncio
  804. @pytest.mark.integration
  805. async def test_configure_pfus_used_regardless_of_slot_material(self, async_client: AsyncClient, printer_factory):
  806. """Provided PFUS* preset is used even when slot has a different material."""
  807. printer = await printer_factory(name="H2D")
  808. mock_client = MagicMock()
  809. mock_client.ams_set_filament_setting.return_value = True
  810. mock_client.extrusion_cali_sel.return_value = True
  811. mock_client.request_status_update.return_value = True
  812. # Slot currently has PETG but user is configuring PLA
  813. mock_status = MagicMock()
  814. mock_status.raw_data = {
  815. "ams": {
  816. "ams": [
  817. {
  818. "id": 2,
  819. "tray": [{"id": 3, "tray_info_idx": "GFG99", "tray_type": "PETG", "tray_color": "FFFFFFFF"}],
  820. }
  821. ]
  822. }
  823. }
  824. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  825. mock_pm.get_client.return_value = mock_client
  826. mock_pm.get_status.return_value = mock_status
  827. response = await async_client.post(
  828. f"/api/v1/printers/{printer.id}/slots/2/3/configure",
  829. params={
  830. "tray_info_idx": "PFUS9ac902733670a9",
  831. "tray_type": "PLA",
  832. "tray_sub_brands": "Devil Design PLA",
  833. "tray_color": "FF0000FF",
  834. "nozzle_temp_min": 190,
  835. "nozzle_temp_max": 230,
  836. },
  837. )
  838. assert response.status_code == 200
  839. call_kwargs = mock_client.ams_set_filament_setting.call_args
  840. # Provided preset wins — slot's material is irrelevant
  841. assert call_kwargs.kwargs["tray_info_idx"] == "PFUS9ac902733670a9"
  842. @pytest.mark.asyncio
  843. @pytest.mark.integration
  844. async def test_configure_empty_id_uses_generic(self, async_client: AsyncClient, printer_factory):
  845. """Empty tray_info_idx (local preset) is replaced with generic."""
  846. printer = await printer_factory(name="H2D")
  847. mock_client = MagicMock()
  848. mock_client.ams_set_filament_setting.return_value = True
  849. mock_client.extrusion_cali_sel.return_value = True
  850. mock_client.request_status_update.return_value = True
  851. mock_status = MagicMock()
  852. mock_status.raw_data = {"ams": {"ams": []}}
  853. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  854. mock_pm.get_client.return_value = mock_client
  855. mock_pm.get_status.return_value = mock_status
  856. response = await async_client.post(
  857. f"/api/v1/printers/{printer.id}/slots/2/3/configure",
  858. params={
  859. "tray_info_idx": "",
  860. "tray_type": "PETG",
  861. "tray_sub_brands": "PETG Basic",
  862. "tray_color": "FFFFFFFF",
  863. "nozzle_temp_min": 220,
  864. "nozzle_temp_max": 260,
  865. },
  866. )
  867. assert response.status_code == 200
  868. call_kwargs = mock_client.ams_set_filament_setting.call_args
  869. assert call_kwargs.kwargs["tray_info_idx"] == "GFG99"
  870. @pytest.mark.asyncio
  871. @pytest.mark.integration
  872. async def test_configure_pfus_preserves_setting_id_pair(self, async_client: AsyncClient, printer_factory):
  873. """Both tray_info_idx=PFUS* and setting_id=PFUS* are forwarded untouched.
  874. Pins the end-to-end contract the frontend #1053 fix relies on: when the
  875. user configures a slot with a custom cloud preset whose cloud detail
  876. has filament_id=null, the frontend sends the setting_id in BOTH fields
  877. and the backend must not collapse either to a generic GF* ID.
  878. """
  879. printer = await printer_factory(name="H2D")
  880. mock_client = MagicMock()
  881. mock_client.ams_set_filament_setting.return_value = True
  882. mock_client.extrusion_cali_sel.return_value = True
  883. mock_client.request_status_update.return_value = True
  884. mock_status = MagicMock()
  885. mock_status.raw_data = {"ams": {"ams": []}}
  886. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  887. mock_pm.get_client.return_value = mock_client
  888. mock_pm.get_status.return_value = mock_status
  889. response = await async_client.post(
  890. f"/api/v1/printers/{printer.id}/slots/128/0/configure",
  891. params={
  892. "tray_info_idx": "PFUSa8fb76f9733e3c",
  893. "tray_type": "ABS",
  894. "tray_sub_brands": "Sting3D ABS",
  895. "tray_color": "000000FF",
  896. "nozzle_temp_min": 240,
  897. "nozzle_temp_max": 280,
  898. "setting_id": "PFUSa8fb76f9733e3c",
  899. },
  900. )
  901. assert response.status_code == 200
  902. call_kwargs = mock_client.ams_set_filament_setting.call_args
  903. assert call_kwargs.kwargs["tray_info_idx"] == "PFUSa8fb76f9733e3c"
  904. assert call_kwargs.kwargs["setting_id"] == "PFUSa8fb76f9733e3c"
  905. # Explicitly assert no generic-collapse happened for this HT slot.
  906. assert call_kwargs.kwargs["tray_info_idx"] != "GFB99"
  907. class TestSkipObjectsAPI:
  908. """Integration tests for skip objects endpoints."""
  909. # ========================================================================
  910. # Get printable objects endpoint
  911. # ========================================================================
  912. @pytest.mark.asyncio
  913. @pytest.mark.integration
  914. async def test_get_objects_not_found(self, async_client: AsyncClient):
  915. """Verify 404 for non-existent printer."""
  916. response = await async_client.get("/api/v1/printers/99999/print/objects")
  917. assert response.status_code == 404
  918. @pytest.mark.asyncio
  919. @pytest.mark.integration
  920. async def test_get_objects_not_connected(self, async_client: AsyncClient, printer_factory):
  921. """Verify error when printer is not connected."""
  922. printer = await printer_factory(name="Disconnected Printer")
  923. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  924. mock_pm.get_client.return_value = None
  925. response = await async_client.get(f"/api/v1/printers/{printer.id}/print/objects")
  926. assert response.status_code == 400
  927. assert "not connected" in response.json()["detail"].lower()
  928. @pytest.mark.asyncio
  929. @pytest.mark.integration
  930. async def test_get_objects_empty(self, async_client: AsyncClient, printer_factory):
  931. """Verify empty objects list when no print is active."""
  932. printer = await printer_factory(name="Idle Printer")
  933. mock_client = MagicMock()
  934. mock_client.state.printable_objects = {}
  935. mock_client.state.skipped_objects = []
  936. mock_client.state.state = "IDLE"
  937. mock_client.state.subtask_name = None # Prevent FTP download attempt
  938. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  939. mock_pm.get_client.return_value = mock_client
  940. response = await async_client.get(f"/api/v1/printers/{printer.id}/print/objects")
  941. assert response.status_code == 200
  942. result = response.json()
  943. assert result["objects"] == []
  944. assert result["total"] == 0
  945. assert result["skipped_count"] == 0
  946. assert result["is_printing"] is False
  947. @pytest.mark.asyncio
  948. @pytest.mark.integration
  949. async def test_get_objects_with_data(self, async_client: AsyncClient, printer_factory):
  950. """Verify objects list when print is active."""
  951. printer = await printer_factory(name="Printing Printer")
  952. mock_client = MagicMock()
  953. mock_client.state.printable_objects = {100: "Part A", 200: "Part B", 300: "Part C"}
  954. mock_client.state.skipped_objects = [200]
  955. mock_client.state.state = "RUNNING"
  956. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  957. mock_pm.get_client.return_value = mock_client
  958. response = await async_client.get(f"/api/v1/printers/{printer.id}/print/objects")
  959. assert response.status_code == 200
  960. result = response.json()
  961. assert result["total"] == 3
  962. assert result["skipped_count"] == 1
  963. assert result["is_printing"] is True
  964. # Check objects have correct structure
  965. objects_by_id = {obj["id"]: obj for obj in result["objects"]}
  966. assert objects_by_id[100]["name"] == "Part A"
  967. assert objects_by_id[100]["skipped"] is False
  968. assert objects_by_id[200]["name"] == "Part B"
  969. assert objects_by_id[200]["skipped"] is True
  970. assert objects_by_id[300]["name"] == "Part C"
  971. assert objects_by_id[300]["skipped"] is False
  972. # ========================================================================
  973. # Skip objects endpoint
  974. # ========================================================================
  975. @pytest.mark.asyncio
  976. @pytest.mark.integration
  977. async def test_get_objects_with_positions(self, async_client: AsyncClient, printer_factory):
  978. """Verify objects list includes position data when available."""
  979. printer = await printer_factory(name="Printing Printer")
  980. # New format with position data
  981. mock_client = MagicMock()
  982. mock_client.state.printable_objects = {
  983. 100: {"name": "Part A", "x": 50.0, "y": 100.0},
  984. 200: {"name": "Part B", "x": 150.0, "y": 100.0},
  985. }
  986. mock_client.state.skipped_objects = []
  987. mock_client.state.state = "RUNNING"
  988. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  989. mock_pm.get_client.return_value = mock_client
  990. response = await async_client.get(f"/api/v1/printers/{printer.id}/print/objects")
  991. assert response.status_code == 200
  992. result = response.json()
  993. assert result["total"] == 2
  994. # Check objects have position data
  995. objects_by_id = {obj["id"]: obj for obj in result["objects"]}
  996. assert objects_by_id[100]["name"] == "Part A"
  997. assert objects_by_id[100]["x"] == 50.0
  998. assert objects_by_id[100]["y"] == 100.0
  999. assert objects_by_id[200]["name"] == "Part B"
  1000. assert objects_by_id[200]["x"] == 150.0
  1001. assert objects_by_id[200]["y"] == 100.0
  1002. # ========================================================================
  1003. # Skip objects endpoint
  1004. # ========================================================================
  1005. @pytest.mark.asyncio
  1006. @pytest.mark.integration
  1007. async def test_skip_objects_not_found(self, async_client: AsyncClient):
  1008. """Verify 404 for non-existent printer."""
  1009. response = await async_client.post("/api/v1/printers/99999/print/skip-objects", json=[100])
  1010. assert response.status_code == 404
  1011. @pytest.mark.asyncio
  1012. @pytest.mark.integration
  1013. async def test_skip_objects_not_connected(self, async_client: AsyncClient, printer_factory):
  1014. """Verify error when printer is not connected."""
  1015. printer = await printer_factory(name="Disconnected Printer")
  1016. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1017. mock_pm.get_client.return_value = None
  1018. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/skip-objects", json=[100])
  1019. assert response.status_code == 400
  1020. assert "not connected" in response.json()["detail"].lower()
  1021. @pytest.mark.asyncio
  1022. @pytest.mark.integration
  1023. async def test_skip_objects_empty_list(self, async_client: AsyncClient, printer_factory):
  1024. """Verify error when no object IDs provided."""
  1025. printer = await printer_factory(name="Printing Printer")
  1026. mock_client = MagicMock()
  1027. mock_client.state.printable_objects = {100: "Part A"}
  1028. mock_client.state.skipped_objects = []
  1029. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1030. mock_pm.get_client.return_value = mock_client
  1031. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/skip-objects", json=[])
  1032. assert response.status_code == 400
  1033. assert "no object" in response.json()["detail"].lower()
  1034. @pytest.mark.asyncio
  1035. @pytest.mark.integration
  1036. async def test_skip_objects_invalid_id(self, async_client: AsyncClient, printer_factory):
  1037. """Verify error when object ID doesn't exist."""
  1038. printer = await printer_factory(name="Printing Printer")
  1039. mock_client = MagicMock()
  1040. mock_client.state.printable_objects = {100: "Part A"}
  1041. mock_client.state.skipped_objects = []
  1042. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1043. mock_pm.get_client.return_value = mock_client
  1044. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/skip-objects", json=[999])
  1045. assert response.status_code == 400
  1046. assert "invalid" in response.json()["detail"].lower()
  1047. @pytest.mark.asyncio
  1048. @pytest.mark.integration
  1049. async def test_skip_objects_success(self, async_client: AsyncClient, printer_factory):
  1050. """Verify successful skip objects request."""
  1051. printer = await printer_factory(name="Printing Printer")
  1052. mock_client = MagicMock()
  1053. mock_client.state.printable_objects = {100: "Part A", 200: "Part B"}
  1054. mock_client.state.skipped_objects = []
  1055. mock_client.skip_objects.return_value = True
  1056. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1057. mock_pm.get_client.return_value = mock_client
  1058. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/skip-objects", json=[100])
  1059. assert response.status_code == 200
  1060. result = response.json()
  1061. assert result["success"] is True
  1062. assert 100 in result["skipped_objects"]
  1063. mock_client.skip_objects.assert_called_once_with([100])
  1064. @pytest.mark.asyncio
  1065. @pytest.mark.integration
  1066. async def test_skip_objects_multiple(self, async_client: AsyncClient, printer_factory):
  1067. """Verify skipping multiple objects at once."""
  1068. printer = await printer_factory(name="Printing Printer")
  1069. mock_client = MagicMock()
  1070. mock_client.state.printable_objects = {100: "Part A", 200: "Part B", 300: "Part C"}
  1071. mock_client.state.skipped_objects = []
  1072. mock_client.skip_objects.return_value = True
  1073. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1074. mock_pm.get_client.return_value = mock_client
  1075. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/skip-objects", json=[100, 200])
  1076. assert response.status_code == 200
  1077. result = response.json()
  1078. assert result["success"] is True
  1079. assert 100 in result["skipped_objects"]
  1080. assert 200 in result["skipped_objects"]
  1081. mock_client.skip_objects.assert_called_once_with([100, 200])
  1082. class TestChamberLightAPI:
  1083. """Integration tests for chamber light control endpoint."""
  1084. @pytest.mark.asyncio
  1085. @pytest.mark.integration
  1086. async def test_chamber_light_not_found(self, async_client: AsyncClient):
  1087. """Verify 404 for non-existent printer."""
  1088. response = await async_client.post("/api/v1/printers/99999/chamber-light?on=true")
  1089. assert response.status_code == 404
  1090. @pytest.mark.asyncio
  1091. @pytest.mark.integration
  1092. async def test_chamber_light_not_connected(self, async_client: AsyncClient, printer_factory):
  1093. """Verify error when printer is not connected."""
  1094. printer = await printer_factory(name="Disconnected Printer")
  1095. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1096. mock_pm.get_client.return_value = None
  1097. response = await async_client.post(f"/api/v1/printers/{printer.id}/chamber-light?on=true")
  1098. assert response.status_code == 400
  1099. assert "not connected" in response.json()["detail"].lower()
  1100. @pytest.mark.asyncio
  1101. @pytest.mark.integration
  1102. async def test_chamber_light_on_success(self, async_client: AsyncClient, printer_factory):
  1103. """Verify successful chamber light on request."""
  1104. printer = await printer_factory(name="Test Printer")
  1105. mock_client = MagicMock()
  1106. mock_client.set_chamber_light.return_value = True
  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}/chamber-light?on=true")
  1110. assert response.status_code == 200
  1111. result = response.json()
  1112. assert result["success"] is True
  1113. assert "on" in result["message"].lower()
  1114. mock_client.set_chamber_light.assert_called_once_with(True)
  1115. @pytest.mark.asyncio
  1116. @pytest.mark.integration
  1117. async def test_chamber_light_off_success(self, async_client: AsyncClient, printer_factory):
  1118. """Verify successful chamber light off request."""
  1119. printer = await printer_factory(name="Test Printer")
  1120. mock_client = MagicMock()
  1121. mock_client.set_chamber_light.return_value = True
  1122. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1123. mock_pm.get_client.return_value = mock_client
  1124. response = await async_client.post(f"/api/v1/printers/{printer.id}/chamber-light?on=false")
  1125. assert response.status_code == 200
  1126. result = response.json()
  1127. assert result["success"] is True
  1128. assert "off" in result["message"].lower()
  1129. mock_client.set_chamber_light.assert_called_once_with(False)
  1130. @pytest.mark.asyncio
  1131. @pytest.mark.integration
  1132. async def test_chamber_light_failure(self, async_client: AsyncClient, printer_factory):
  1133. """Verify error handling when chamber light control fails."""
  1134. printer = await printer_factory(name="Test Printer")
  1135. mock_client = MagicMock()
  1136. mock_client.set_chamber_light.return_value = False
  1137. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1138. mock_pm.get_client.return_value = mock_client
  1139. response = await async_client.post(f"/api/v1/printers/{printer.id}/chamber-light?on=true")
  1140. assert response.status_code == 500
  1141. assert "failed" in response.json()["detail"].lower()
  1142. class TestAirductModeAPI:
  1143. """Integration tests for the airduct mode endpoint (P2S/H2*)."""
  1144. @pytest.mark.asyncio
  1145. @pytest.mark.integration
  1146. async def test_invalid_mode_rejected(self, async_client: AsyncClient, printer_factory):
  1147. printer = await printer_factory(name="P", model="P2S")
  1148. response = await async_client.post(f"/api/v1/printers/{printer.id}/airduct-mode?mode=foo")
  1149. assert response.status_code == 400
  1150. @pytest.mark.asyncio
  1151. @pytest.mark.integration
  1152. async def test_not_connected(self, async_client: AsyncClient, printer_factory):
  1153. printer = await printer_factory(name="P", model="P2S")
  1154. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1155. mock_pm.get_client.return_value = None
  1156. response = await async_client.post(f"/api/v1/printers/{printer.id}/airduct-mode?mode=cooling")
  1157. assert response.status_code == 400
  1158. @pytest.mark.asyncio
  1159. @pytest.mark.integration
  1160. async def test_cooling_success(self, async_client: AsyncClient, printer_factory):
  1161. printer = await printer_factory(name="P", model="P2S")
  1162. mock_client = MagicMock()
  1163. mock_client.set_airduct_mode.return_value = True
  1164. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1165. mock_pm.get_client.return_value = mock_client
  1166. response = await async_client.post(f"/api/v1/printers/{printer.id}/airduct-mode?mode=cooling")
  1167. assert response.status_code == 200
  1168. assert response.json()["success"] is True
  1169. mock_client.set_airduct_mode.assert_called_once_with("cooling")
  1170. @pytest.mark.asyncio
  1171. @pytest.mark.integration
  1172. async def test_heating_failure_returns_500(self, async_client: AsyncClient, printer_factory):
  1173. printer = await printer_factory(name="P", model="P2S")
  1174. mock_client = MagicMock()
  1175. mock_client.set_airduct_mode.return_value = False
  1176. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1177. mock_pm.get_client.return_value = mock_client
  1178. response = await async_client.post(f"/api/v1/printers/{printer.id}/airduct-mode?mode=heating")
  1179. assert response.status_code == 500
  1180. class TestClearHMSErrorsAPI:
  1181. """Integration tests for clear HMS errors endpoint."""
  1182. @pytest.mark.asyncio
  1183. @pytest.mark.integration
  1184. async def test_clear_hms_errors_not_found(self, async_client: AsyncClient):
  1185. """Verify 404 for non-existent printer."""
  1186. response = await async_client.post("/api/v1/printers/99999/hms/clear")
  1187. assert response.status_code == 404
  1188. @pytest.mark.asyncio
  1189. @pytest.mark.integration
  1190. async def test_clear_hms_errors_not_connected(self, async_client: AsyncClient, printer_factory):
  1191. """Verify error when printer is not connected."""
  1192. printer = await printer_factory(name="Disconnected Printer")
  1193. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1194. mock_pm.get_client.return_value = None
  1195. response = await async_client.post(f"/api/v1/printers/{printer.id}/hms/clear")
  1196. assert response.status_code == 400
  1197. assert "not connected" in response.json()["detail"].lower()
  1198. @pytest.mark.asyncio
  1199. @pytest.mark.integration
  1200. async def test_clear_hms_errors_success(self, async_client: AsyncClient, printer_factory):
  1201. """Verify successful clear HMS errors request."""
  1202. printer = await printer_factory(name="Test Printer")
  1203. mock_client = MagicMock()
  1204. mock_client.clear_hms_errors.return_value = True
  1205. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1206. mock_pm.get_client.return_value = mock_client
  1207. response = await async_client.post(f"/api/v1/printers/{printer.id}/hms/clear")
  1208. assert response.status_code == 200
  1209. result = response.json()
  1210. assert result["success"] is True
  1211. assert "cleared" in result["message"].lower()
  1212. mock_client.clear_hms_errors.assert_called_once()
  1213. @pytest.mark.asyncio
  1214. @pytest.mark.integration
  1215. async def test_clear_hms_errors_failure(self, async_client: AsyncClient, printer_factory):
  1216. """Verify error handling when clear HMS errors fails."""
  1217. printer = await printer_factory(name="Test Printer")
  1218. mock_client = MagicMock()
  1219. mock_client.clear_hms_errors.return_value = False
  1220. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1221. mock_pm.get_client.return_value = mock_client
  1222. response = await async_client.post(f"/api/v1/printers/{printer.id}/hms/clear")
  1223. assert response.status_code == 500
  1224. assert "failed" in response.json()["detail"].lower()
  1225. def _build_h2d_state(*, ams_id: int = 0, tray_id: int = 2, cali_idx: int = 5):
  1226. """Build a MagicMock PrinterState for an H2D printer with a single BL spool tray.
  1227. Used by both TestApplyPaAfterRefresh (Phase 13 P13-T-BE-1) and the K-profile
  1228. persistence tests below. The tray data passes is_bambu_tag (32-char non-zero
  1229. tray_uuid + non-empty tray_info_idx).
  1230. """
  1231. nozzle = MagicMock(nozzle_diameter="0.4")
  1232. state = MagicMock()
  1233. state.nozzles = [nozzle]
  1234. state.ams_extruder_map = {"0": 0}
  1235. state.raw_data = {
  1236. "ams": [
  1237. {
  1238. "id": ams_id,
  1239. "tray": [
  1240. {
  1241. "id": tray_id,
  1242. "tray_type": "PLA",
  1243. "tag_uid": "AABBCC1122334400",
  1244. "tray_uuid": "11223344556677880011223344556677",
  1245. "tray_info_idx": "GFL05",
  1246. "cali_idx": cali_idx,
  1247. }
  1248. ],
  1249. }
  1250. ]
  1251. }
  1252. return state
  1253. def _patch_async_session_to(db_session):
  1254. """Patch backend.app.core.database.async_session so calls inside the function
  1255. under test reuse the test fixture's db_session.
  1256. `_apply_pa_after_refresh` lazy-imports `from backend.app.core.database import
  1257. async_session` at runtime (line 2849). When we patch the source module
  1258. before the call, the lazy import picks up the patched object.
  1259. Returns the patch context manager; use as `with _patch_async_session_to(db_session):`.
  1260. Pattern verified against test_print_lifecycle.py:38-42.
  1261. """
  1262. cm = AsyncMock()
  1263. cm.__aenter__ = AsyncMock(return_value=db_session)
  1264. cm.__aexit__ = AsyncMock(return_value=None)
  1265. return patch("backend.app.core.database.async_session", return_value=cm)
  1266. class TestApplyPaAfterRefresh:
  1267. """Phase 13 P13-T-BE-1: _apply_pa_after_refresh K-profile cascade.
  1268. Verifies the 3-stage cascade (local SpoolKProfile → Spoolman SpoolmanKProfile
  1269. → live tray.cali_idx fallback) and the Bug A regression (kp.extruder, not
  1270. kp.extruder_id, after the Phase 13 P13-2a fix).
  1271. `_apply_pa_after_refresh` is a free function spawned via asyncio.create_task
  1272. from the /ams-refresh endpoint. Tests call it directly because awaiting the
  1273. spawned task in an HTTP test would require sleeping past the 5-second guard
  1274. that delays MQTT until RFID re-read finishes.
  1275. """
  1276. @pytest.mark.asyncio
  1277. @pytest.mark.integration
  1278. async def test_local_kp_match_sends_stored_cali_idx(self, db_session, printer_factory):
  1279. """Local SpoolAssignment + matching SpoolKProfile → stored cali_idx wins."""
  1280. from backend.app.api.routes.printers import _apply_pa_after_refresh
  1281. from backend.app.models.spool import Spool
  1282. from backend.app.models.spool_assignment import SpoolAssignment
  1283. from backend.app.models.spool_k_profile import SpoolKProfile
  1284. printer = await printer_factory()
  1285. spool = Spool(material="PLA", color_name="Red", rgba="FF0000FF")
  1286. db_session.add(spool)
  1287. await db_session.flush()
  1288. db_session.add(
  1289. SpoolAssignment(
  1290. spool_id=spool.id,
  1291. printer_id=printer.id,
  1292. ams_id=0,
  1293. tray_id=2,
  1294. )
  1295. )
  1296. db_session.add(
  1297. SpoolKProfile(
  1298. spool_id=spool.id,
  1299. printer_id=printer.id,
  1300. extruder=0,
  1301. nozzle_diameter="0.4",
  1302. k_value=0.025,
  1303. cali_idx=42,
  1304. )
  1305. )
  1306. await db_session.commit()
  1307. mock_client = MagicMock()
  1308. mock_client.extrusion_cali_sel = MagicMock(return_value=True)
  1309. state = _build_h2d_state(cali_idx=5)
  1310. with (
  1311. patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
  1312. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  1313. _patch_async_session_to(db_session),
  1314. ):
  1315. mock_pm.get_client.return_value = mock_client
  1316. mock_pm.get_status.return_value = state
  1317. await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
  1318. mock_client.extrusion_cali_sel.assert_called_once()
  1319. kwargs = mock_client.extrusion_cali_sel.call_args.kwargs
  1320. assert kwargs["cali_idx"] == 42 # stored profile, not 5 (live)
  1321. @pytest.mark.asyncio
  1322. @pytest.mark.integration
  1323. async def test_local_no_kp_uses_live_cali_idx(self, db_session, printer_factory):
  1324. """Local SpoolAssignment but no matching SpoolKProfile → live cali_idx (Stage 3)."""
  1325. from backend.app.api.routes.printers import _apply_pa_after_refresh
  1326. from backend.app.models.spool import Spool
  1327. from backend.app.models.spool_assignment import SpoolAssignment
  1328. printer = await printer_factory()
  1329. spool = Spool(material="PLA")
  1330. db_session.add(spool)
  1331. await db_session.flush()
  1332. db_session.add(
  1333. SpoolAssignment(
  1334. spool_id=spool.id,
  1335. printer_id=printer.id,
  1336. ams_id=0,
  1337. tray_id=2,
  1338. )
  1339. )
  1340. await db_session.commit()
  1341. mock_client = MagicMock()
  1342. mock_client.extrusion_cali_sel = MagicMock(return_value=True)
  1343. state = _build_h2d_state(cali_idx=5)
  1344. with (
  1345. patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
  1346. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  1347. _patch_async_session_to(db_session),
  1348. ):
  1349. mock_pm.get_client.return_value = mock_client
  1350. mock_pm.get_status.return_value = state
  1351. await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
  1352. mock_client.extrusion_cali_sel.assert_called_once()
  1353. kwargs = mock_client.extrusion_cali_sel.call_args.kwargs
  1354. assert kwargs["cali_idx"] == 5 # live tray.cali_idx fallback
  1355. @pytest.mark.asyncio
  1356. @pytest.mark.integration
  1357. async def test_spoolman_kp_when_no_local(self, db_session, printer_factory):
  1358. """No local assignment + Spoolman SlotAssignment + SpoolmanKProfile → Spoolman cali_idx."""
  1359. from backend.app.api.routes.printers import _apply_pa_after_refresh
  1360. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  1361. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  1362. printer = await printer_factory()
  1363. db_session.add(
  1364. SpoolmanSlotAssignment(
  1365. printer_id=printer.id,
  1366. ams_id=0,
  1367. tray_id=2,
  1368. spoolman_spool_id=99,
  1369. )
  1370. )
  1371. db_session.add(
  1372. SpoolmanKProfile(
  1373. spoolman_spool_id=99,
  1374. printer_id=printer.id,
  1375. extruder=0,
  1376. nozzle_diameter="0.4",
  1377. k_value=0.030,
  1378. cali_idx=77,
  1379. )
  1380. )
  1381. await db_session.commit()
  1382. mock_client = MagicMock()
  1383. mock_client.extrusion_cali_sel = MagicMock(return_value=True)
  1384. state = _build_h2d_state(cali_idx=5)
  1385. with (
  1386. patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
  1387. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  1388. _patch_async_session_to(db_session),
  1389. ):
  1390. mock_pm.get_client.return_value = mock_client
  1391. mock_pm.get_status.return_value = state
  1392. await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
  1393. mock_client.extrusion_cali_sel.assert_called_once()
  1394. kwargs = mock_client.extrusion_cali_sel.call_args.kwargs
  1395. assert kwargs["cali_idx"] == 77 # Spoolman stored profile
  1396. @pytest.mark.asyncio
  1397. @pytest.mark.integration
  1398. async def test_spoolman_no_kp_uses_live(self, db_session, printer_factory):
  1399. """Spoolman SlotAssignment but no SpoolmanKProfile → live cali_idx (Stage 3)."""
  1400. from backend.app.api.routes.printers import _apply_pa_after_refresh
  1401. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  1402. printer = await printer_factory()
  1403. db_session.add(
  1404. SpoolmanSlotAssignment(
  1405. printer_id=printer.id,
  1406. ams_id=0,
  1407. tray_id=2,
  1408. spoolman_spool_id=99,
  1409. )
  1410. )
  1411. await db_session.commit()
  1412. mock_client = MagicMock()
  1413. mock_client.extrusion_cali_sel = MagicMock(return_value=True)
  1414. state = _build_h2d_state(cali_idx=5)
  1415. with (
  1416. patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
  1417. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  1418. _patch_async_session_to(db_session),
  1419. ):
  1420. mock_pm.get_client.return_value = mock_client
  1421. mock_pm.get_status.return_value = state
  1422. await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
  1423. mock_client.extrusion_cali_sel.assert_called_once()
  1424. assert mock_client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 5
  1425. @pytest.mark.asyncio
  1426. @pytest.mark.integration
  1427. async def test_no_assignment_uses_live(self, db_session, printer_factory):
  1428. """No assignment of any kind + live cali_idx >= 0 → live fallback."""
  1429. from backend.app.api.routes.printers import _apply_pa_after_refresh
  1430. printer = await printer_factory()
  1431. mock_client = MagicMock()
  1432. mock_client.extrusion_cali_sel = MagicMock(return_value=True)
  1433. state = _build_h2d_state(cali_idx=5)
  1434. with (
  1435. patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
  1436. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  1437. _patch_async_session_to(db_session),
  1438. ):
  1439. mock_pm.get_client.return_value = mock_client
  1440. mock_pm.get_status.return_value = state
  1441. await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
  1442. mock_client.extrusion_cali_sel.assert_called_once()
  1443. assert mock_client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 5
  1444. @pytest.mark.asyncio
  1445. @pytest.mark.integration
  1446. async def test_negative_live_cali_idx_skipped(self, db_session, printer_factory):
  1447. """No assignment + live cali_idx=-1 → no MQTT call (invalid live value)."""
  1448. from backend.app.api.routes.printers import _apply_pa_after_refresh
  1449. printer = await printer_factory()
  1450. mock_client = MagicMock()
  1451. mock_client.extrusion_cali_sel = MagicMock(return_value=True)
  1452. state = _build_h2d_state(cali_idx=-1)
  1453. with (
  1454. patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
  1455. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  1456. _patch_async_session_to(db_session),
  1457. ):
  1458. mock_pm.get_client.return_value = mock_client
  1459. mock_pm.get_status.return_value = state
  1460. await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
  1461. mock_client.extrusion_cali_sel.assert_not_called()
  1462. @pytest.mark.asyncio
  1463. @pytest.mark.integration
  1464. async def test_no_assignment_no_live_cali_idx_no_call(self, db_session, printer_factory):
  1465. """No assignment of any kind AND no live cali_idx in tray → no MQTT call.
  1466. Distinct from test_negative_live_cali_idx_skipped: that test has
  1467. cali_idx=-1 in raw_data; this one omits the field entirely (returns
  1468. None from .get("cali_idx")). Both must result in no MQTT call.
  1469. """
  1470. from backend.app.api.routes.printers import _apply_pa_after_refresh
  1471. printer = await printer_factory()
  1472. mock_client = MagicMock()
  1473. mock_client.extrusion_cali_sel = MagicMock(return_value=True)
  1474. # State with NO cali_idx field on tray at all
  1475. nozzle = MagicMock(nozzle_diameter="0.4")
  1476. state = MagicMock()
  1477. state.nozzles = [nozzle]
  1478. state.ams_extruder_map = {"0": 0}
  1479. state.raw_data = {
  1480. "ams": [
  1481. {
  1482. "id": 0,
  1483. "tray": [
  1484. {
  1485. "id": 2,
  1486. "tray_type": "PLA",
  1487. "tag_uid": "AABBCC1122334400",
  1488. "tray_uuid": "11223344556677880011223344556677",
  1489. "tray_info_idx": "GFL05",
  1490. # cali_idx field intentionally omitted
  1491. }
  1492. ],
  1493. }
  1494. ]
  1495. }
  1496. with (
  1497. patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
  1498. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  1499. _patch_async_session_to(db_session),
  1500. ):
  1501. mock_pm.get_client.return_value = mock_client
  1502. mock_pm.get_status.return_value = state
  1503. await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
  1504. mock_client.extrusion_cali_sel.assert_not_called()
  1505. @pytest.mark.asyncio
  1506. @pytest.mark.integration
  1507. async def test_extruder_mismatch_uses_kp_as_fallback(self, db_session, printer_factory):
  1508. """K-profile for extruder=1 but slot is extruder=0 → no exact match,
  1509. but the kp is used as extruder-agnostic fallback rather than dropped.
  1510. Hard-skipping on extruder mismatch was the previous behavior; in
  1511. practice it caused stored K-profiles to be silently ignored whenever
  1512. the AMS-extruder mapping had shifted (or when only one of the two
  1513. extruders was ever calibrated for a given spool). The cascade now
  1514. prefers an exact extruder match but falls back to any matching kp
  1515. for the same printer + nozzle.
  1516. """
  1517. from backend.app.api.routes.printers import _apply_pa_after_refresh
  1518. from backend.app.models.spool import Spool
  1519. from backend.app.models.spool_assignment import SpoolAssignment
  1520. from backend.app.models.spool_k_profile import SpoolKProfile
  1521. printer = await printer_factory()
  1522. spool = Spool(material="PLA")
  1523. db_session.add(spool)
  1524. await db_session.flush()
  1525. db_session.add(
  1526. SpoolAssignment(
  1527. spool_id=spool.id,
  1528. printer_id=printer.id,
  1529. ams_id=0,
  1530. tray_id=2,
  1531. )
  1532. )
  1533. # K-profile is for extruder=1, but slot's ams_extruder_map["0"]=0
  1534. db_session.add(
  1535. SpoolKProfile(
  1536. spool_id=spool.id,
  1537. printer_id=printer.id,
  1538. extruder=1,
  1539. nozzle_diameter="0.4",
  1540. k_value=0.025,
  1541. cali_idx=42,
  1542. )
  1543. )
  1544. await db_session.commit()
  1545. mock_client = MagicMock()
  1546. mock_client.extrusion_cali_sel = MagicMock(return_value=True)
  1547. state = _build_h2d_state(cali_idx=5)
  1548. with (
  1549. patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
  1550. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  1551. _patch_async_session_to(db_session),
  1552. ):
  1553. mock_pm.get_client.return_value = mock_client
  1554. mock_pm.get_status.return_value = state
  1555. await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
  1556. # No exact extruder match, but the stored kp wins as the
  1557. # extruder-agnostic fallback over live cali_idx=5.
  1558. mock_client.extrusion_cali_sel.assert_called_once()
  1559. assert mock_client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 42
  1560. @pytest.mark.asyncio
  1561. @pytest.mark.integration
  1562. async def test_extruder_exact_match_preferred_over_fallback(
  1563. self,
  1564. db_session,
  1565. printer_factory,
  1566. ):
  1567. """When two kp rows exist, one with matching extruder and one without,
  1568. the exact-extruder kp wins (extruder-agnostic fallback only fires when
  1569. no exact match exists).
  1570. """
  1571. from backend.app.api.routes.printers import _apply_pa_after_refresh
  1572. from backend.app.models.spool import Spool
  1573. from backend.app.models.spool_assignment import SpoolAssignment
  1574. from backend.app.models.spool_k_profile import SpoolKProfile
  1575. printer = await printer_factory()
  1576. spool = Spool(material="PLA")
  1577. db_session.add(spool)
  1578. await db_session.flush()
  1579. db_session.add(
  1580. SpoolAssignment(
  1581. spool_id=spool.id,
  1582. printer_id=printer.id,
  1583. ams_id=0,
  1584. tray_id=2,
  1585. )
  1586. )
  1587. # Two kp rows: extruder=1 (mismatch w/ slot extruder=0) and extruder=0 (exact)
  1588. db_session.add(
  1589. SpoolKProfile(
  1590. spool_id=spool.id,
  1591. printer_id=printer.id,
  1592. extruder=1,
  1593. nozzle_diameter="0.4",
  1594. k_value=0.030,
  1595. cali_idx=99,
  1596. )
  1597. )
  1598. db_session.add(
  1599. SpoolKProfile(
  1600. spool_id=spool.id,
  1601. printer_id=printer.id,
  1602. extruder=0,
  1603. nozzle_diameter="0.4",
  1604. k_value=0.025,
  1605. cali_idx=42,
  1606. )
  1607. )
  1608. await db_session.commit()
  1609. mock_client = MagicMock()
  1610. mock_client.extrusion_cali_sel = MagicMock(return_value=True)
  1611. state = _build_h2d_state(cali_idx=5)
  1612. with (
  1613. patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
  1614. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  1615. _patch_async_session_to(db_session),
  1616. ):
  1617. mock_pm.get_client.return_value = mock_client
  1618. mock_pm.get_status.return_value = state
  1619. await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
  1620. # Exact-extruder=0 kp wins (cali_idx=42), not the extruder=1 fallback (99)
  1621. mock_client.extrusion_cali_sel.assert_called_once()
  1622. assert mock_client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 42
  1623. @pytest.mark.asyncio
  1624. @pytest.mark.integration
  1625. async def test_regression_bug_a_kp_extruder_attr(self, db_session, printer_factory):
  1626. """Regression test for Phase 13 P13-2a Bug A.
  1627. Pre-fix Z.2910 used `kp.extruder_id` (AttributeError on SpoolKProfile,
  1628. silently swallowed by outer try/except). On dual-nozzle printers with
  1629. slot_extruder != None this caused the K-profile match loop to crash.
  1630. After P13-2a the field name is correct: `kp.extruder`.
  1631. """
  1632. from backend.app.api.routes.printers import _apply_pa_after_refresh
  1633. from backend.app.models.spool import Spool
  1634. from backend.app.models.spool_assignment import SpoolAssignment
  1635. from backend.app.models.spool_k_profile import SpoolKProfile
  1636. printer = await printer_factory()
  1637. spool = Spool(material="PLA")
  1638. db_session.add(spool)
  1639. await db_session.flush()
  1640. db_session.add(
  1641. SpoolAssignment(
  1642. spool_id=spool.id,
  1643. printer_id=printer.id,
  1644. ams_id=0,
  1645. tray_id=2,
  1646. )
  1647. )
  1648. # extruder=0 matches slot_extruder=0 (from ams_extruder_map={"0":0})
  1649. db_session.add(
  1650. SpoolKProfile(
  1651. spool_id=spool.id,
  1652. printer_id=printer.id,
  1653. extruder=0,
  1654. nozzle_diameter="0.4",
  1655. k_value=0.025,
  1656. cali_idx=42,
  1657. )
  1658. )
  1659. await db_session.commit()
  1660. mock_client = MagicMock()
  1661. mock_client.extrusion_cali_sel = MagicMock(return_value=True)
  1662. state = _build_h2d_state(cali_idx=5)
  1663. with (
  1664. patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
  1665. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  1666. _patch_async_session_to(db_session),
  1667. ):
  1668. mock_pm.get_client.return_value = mock_client
  1669. mock_pm.get_status.return_value = state
  1670. await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
  1671. # If Bug A regressed (kp.extruder_id), the loop would AttributeError → silent fail
  1672. # → no extrusion_cali_sel call. Post-fix the loop matches and sends cali_idx=42.
  1673. mock_client.extrusion_cali_sel.assert_called_once()
  1674. assert mock_client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 42
  1675. @pytest.mark.asyncio
  1676. @pytest.mark.integration
  1677. async def test_tag_fallback_finds_spool_when_assignment_missing(
  1678. self,
  1679. db_session,
  1680. printer_factory,
  1681. ):
  1682. """Stage 1b regression for the maintainer's #2 reproducer on H2D:
  1683. reset slot, trigger re-read → slot ends up on the default K-profile
  1684. instead of the spool's stored profile.
  1685. Setup mirrors the bug:
  1686. - Spool has tray_uuid set (the RFID tag was registered earlier).
  1687. - SpoolKProfile exists for that spool with cali_idx=42.
  1688. - NO SpoolAssignment row — the reset deleted it before the re-read
  1689. triggered _apply_pa_after_refresh, and tag-auto-detect has not
  1690. re-created it yet within the 5 s sleep window.
  1691. - Live tray.cali_idx=5 (firmware-default after the RFID re-read).
  1692. Without Stage 1b the cascade falls through to Stage 3 and re-asserts
  1693. the firmware-default cali_idx=5. With Stage 1b it locates the spool by
  1694. the live tray's tray_uuid and applies the stored cali_idx=42.
  1695. """
  1696. from backend.app.api.routes.printers import _apply_pa_after_refresh
  1697. from backend.app.models.spool import Spool
  1698. from backend.app.models.spool_k_profile import SpoolKProfile
  1699. printer = await printer_factory()
  1700. # Spool with tray_uuid matching the one _build_h2d_state puts on the tray
  1701. spool = Spool(
  1702. material="PLA",
  1703. color_name="Red",
  1704. rgba="FF0000FF",
  1705. tray_uuid="11223344556677880011223344556677",
  1706. tag_uid="AABBCC1122334400",
  1707. )
  1708. db_session.add(spool)
  1709. await db_session.flush()
  1710. # K-profile is bound to the spool, not to a slot
  1711. db_session.add(
  1712. SpoolKProfile(
  1713. spool_id=spool.id,
  1714. printer_id=printer.id,
  1715. extruder=0,
  1716. nozzle_diameter="0.4",
  1717. k_value=0.025,
  1718. cali_idx=42,
  1719. )
  1720. )
  1721. # NOTE: deliberately no SpoolAssignment — that's the bug condition.
  1722. await db_session.commit()
  1723. mock_client = MagicMock()
  1724. mock_client.extrusion_cali_sel = MagicMock(return_value=True)
  1725. state = _build_h2d_state(cali_idx=5)
  1726. with (
  1727. patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
  1728. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  1729. _patch_async_session_to(db_session),
  1730. ):
  1731. mock_pm.get_client.return_value = mock_client
  1732. mock_pm.get_status.return_value = state
  1733. await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
  1734. # Stage 1b should match the spool by tray_uuid → stored cali_idx=42 wins
  1735. # over live cali_idx=5. Pre-fix this would have been 5 (firmware default).
  1736. mock_client.extrusion_cali_sel.assert_called_once()
  1737. assert mock_client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 42
  1738. @pytest.mark.asyncio
  1739. @pytest.mark.integration
  1740. async def test_tag_fallback_matches_by_tag_uid_when_uuid_zero(
  1741. self,
  1742. db_session,
  1743. printer_factory,
  1744. ):
  1745. """Stage 1b: when tray_uuid is the zero sentinel but tag_uid is real,
  1746. match by tag_uid. Older firmwares occasionally report a zero tray_uuid
  1747. right after RFID re-read while the tag_uid is already populated."""
  1748. from backend.app.api.routes.printers import _apply_pa_after_refresh
  1749. from backend.app.models.spool import Spool
  1750. from backend.app.models.spool_k_profile import SpoolKProfile
  1751. printer = await printer_factory()
  1752. # Spool indexed by tag_uid, not tray_uuid
  1753. spool = Spool(material="PLA", tag_uid="AABBCC1122334400")
  1754. db_session.add(spool)
  1755. await db_session.flush()
  1756. db_session.add(
  1757. SpoolKProfile(
  1758. spool_id=spool.id,
  1759. printer_id=printer.id,
  1760. extruder=0,
  1761. nozzle_diameter="0.4",
  1762. k_value=0.025,
  1763. cali_idx=99,
  1764. )
  1765. )
  1766. await db_session.commit()
  1767. mock_client = MagicMock()
  1768. mock_client.extrusion_cali_sel = MagicMock(return_value=True)
  1769. # Build a state where the tray reports a real tag_uid but a zero tray_uuid
  1770. # while still passing is_bambu_tag (tag_uid + tray_info_idx is sufficient).
  1771. state = _build_h2d_state(cali_idx=5)
  1772. state.raw_data["ams"][0]["tray"][0]["tray_uuid"] = "00000000000000000000000000000000"
  1773. with (
  1774. patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
  1775. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  1776. _patch_async_session_to(db_session),
  1777. ):
  1778. mock_pm.get_client.return_value = mock_client
  1779. mock_pm.get_status.return_value = state
  1780. await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
  1781. mock_client.extrusion_cali_sel.assert_called_once()
  1782. assert mock_client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 99
  1783. @pytest.mark.asyncio
  1784. @pytest.mark.integration
  1785. async def test_tag_fallback_skipped_when_zero_sentinels(
  1786. self,
  1787. db_session,
  1788. printer_factory,
  1789. ):
  1790. """Stage 1b: when both tray_uuid and tag_uid are zero sentinels, the
  1791. fallback must not match any spool (would otherwise pick up an
  1792. unrelated spool created with empty/zero tag fields). Falls through
  1793. to Stage 3 live cali_idx as before.
  1794. """
  1795. from backend.app.api.routes.printers import _apply_pa_after_refresh
  1796. from backend.app.models.spool import Spool
  1797. from backend.app.models.spool_k_profile import SpoolKProfile
  1798. printer = await printer_factory()
  1799. # Decoy spool with no tag info — must NOT match
  1800. spool = Spool(material="PLA")
  1801. db_session.add(spool)
  1802. await db_session.flush()
  1803. db_session.add(
  1804. SpoolKProfile(
  1805. spool_id=spool.id,
  1806. printer_id=printer.id,
  1807. extruder=0,
  1808. nozzle_diameter="0.4",
  1809. k_value=0.025,
  1810. cali_idx=42,
  1811. )
  1812. )
  1813. await db_session.commit()
  1814. mock_client = MagicMock()
  1815. mock_client.extrusion_cali_sel = MagicMock(return_value=True)
  1816. state = _build_h2d_state(cali_idx=7)
  1817. # Force both tag fields to the zero sentinels but keep tray_info_idx
  1818. # so is_bambu_tag still passes (preset present)
  1819. state.raw_data["ams"][0]["tray"][0]["tag_uid"] = "0000000000000000"
  1820. state.raw_data["ams"][0]["tray"][0]["tray_uuid"] = "00000000000000000000000000000000"
  1821. with (
  1822. patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
  1823. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  1824. _patch_async_session_to(db_session),
  1825. ):
  1826. mock_pm.get_client.return_value = mock_client
  1827. mock_pm.get_status.return_value = state
  1828. # is_bambu_tag actually rejects both-zero + only-preset, so the
  1829. # function returns early. We just want to confirm we didn't blow
  1830. # up scanning for a tag-fallback spool.
  1831. await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
  1832. # is_bambu_tag short-circuits early when both UID and UUID are zero,
  1833. # so no MQTT call should fire and the decoy spool's cali_idx=42 must
  1834. # NOT leak through.
  1835. if mock_client.extrusion_cali_sel.called:
  1836. assert mock_client.extrusion_cali_sel.call_args.kwargs["cali_idx"] != 42
  1837. class TestConfigureAmsSlotPersistsKProfile:
  1838. """Phase 13 P13-T-BE-2: configure_ams_slot persists K-profile to DB.
  1839. Pre-Phase-13 the endpoint sent extrusion_cali_sel via MQTT but never
  1840. recorded the choice in spool_k_profile / spoolman_k_profile, so the next
  1841. RFID re-read had no stored profile to apply.
  1842. """
  1843. @pytest.mark.asyncio
  1844. @pytest.mark.integration
  1845. async def test_writes_spoolman_kprofile_when_spoolman_assigned(
  1846. self,
  1847. async_client: AsyncClient,
  1848. db_session,
  1849. printer_factory,
  1850. ):
  1851. """SpoolmanSlotAssignment present → SpoolmanKProfile row created with cali_idx + k_value + name."""
  1852. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  1853. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  1854. printer = await printer_factory(model="H2D")
  1855. db_session.add(
  1856. SpoolmanSlotAssignment(
  1857. printer_id=printer.id,
  1858. ams_id=0,
  1859. tray_id=3,
  1860. spoolman_spool_id=216,
  1861. )
  1862. )
  1863. await db_session.commit()
  1864. mock_client = MagicMock()
  1865. mock_client.ams_set_filament_setting.return_value = True
  1866. mock_client.extrusion_cali_sel.return_value = True
  1867. mock_client.extrusion_cali_set.return_value = True
  1868. mock_client.request_status_update.return_value = True
  1869. mock_state = MagicMock()
  1870. mock_state.ams_extruder_map = {"0": 0}
  1871. mock_state.raw_data = {"ams": {"ams": []}}
  1872. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1873. mock_pm.get_client.return_value = mock_client
  1874. mock_pm.get_status.return_value = mock_state
  1875. response = await async_client.post(
  1876. f"/api/v1/printers/{printer.id}/slots/0/3/configure",
  1877. params={
  1878. "tray_info_idx": "GFL05",
  1879. "tray_type": "PLA",
  1880. "tray_sub_brands": "Bambu PLA Metal",
  1881. "tray_color": "FF8800FF",
  1882. "nozzle_temp_min": 220,
  1883. "nozzle_temp_max": 240,
  1884. "cali_idx": 5,
  1885. "nozzle_diameter": "0.4",
  1886. "k_value": 0.022,
  1887. },
  1888. )
  1889. assert response.status_code == 200
  1890. kp_result = await db_session.execute(select(SpoolmanKProfile).where(SpoolmanKProfile.spoolman_spool_id == 216))
  1891. kp = kp_result.scalar_one_or_none()
  1892. assert kp is not None
  1893. assert kp.cali_idx == 5
  1894. assert kp.k_value == pytest.approx(0.022)
  1895. assert kp.extruder == 0
  1896. assert kp.nozzle_diameter == "0.4"
  1897. assert kp.name == "Bambu PLA Metal"
  1898. @pytest.mark.asyncio
  1899. @pytest.mark.integration
  1900. async def test_writes_spool_kprofile_when_local_assigned(
  1901. self,
  1902. async_client: AsyncClient,
  1903. db_session,
  1904. printer_factory,
  1905. ):
  1906. """Local SpoolAssignment present → SpoolKProfile row created."""
  1907. from backend.app.models.spool import Spool
  1908. from backend.app.models.spool_assignment import SpoolAssignment
  1909. from backend.app.models.spool_k_profile import SpoolKProfile
  1910. printer = await printer_factory(model="H2D")
  1911. spool = Spool(material="PLA", color_name="Red", rgba="FF0000FF")
  1912. db_session.add(spool)
  1913. await db_session.flush()
  1914. db_session.add(
  1915. SpoolAssignment(
  1916. spool_id=spool.id,
  1917. printer_id=printer.id,
  1918. ams_id=0,
  1919. tray_id=3,
  1920. )
  1921. )
  1922. await db_session.commit()
  1923. spool_id = spool.id
  1924. mock_client = MagicMock()
  1925. mock_client.ams_set_filament_setting.return_value = True
  1926. mock_client.extrusion_cali_sel.return_value = True
  1927. mock_client.extrusion_cali_set.return_value = True
  1928. mock_client.request_status_update.return_value = True
  1929. mock_state = MagicMock()
  1930. mock_state.ams_extruder_map = {"0": 0}
  1931. mock_state.raw_data = {"ams": {"ams": []}}
  1932. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1933. mock_pm.get_client.return_value = mock_client
  1934. mock_pm.get_status.return_value = mock_state
  1935. response = await async_client.post(
  1936. f"/api/v1/printers/{printer.id}/slots/0/3/configure",
  1937. params={
  1938. "tray_info_idx": "PFUSdev01",
  1939. "tray_type": "PLA",
  1940. "tray_sub_brands": "Devil Design PLA",
  1941. "tray_color": "FF0000FF",
  1942. "nozzle_temp_min": 220,
  1943. "nozzle_temp_max": 240,
  1944. "cali_idx": 7,
  1945. "nozzle_diameter": "0.4",
  1946. "k_value": 0.028,
  1947. },
  1948. )
  1949. assert response.status_code == 200
  1950. kp_result = await db_session.execute(select(SpoolKProfile).where(SpoolKProfile.spool_id == spool_id))
  1951. kp = kp_result.scalar_one_or_none()
  1952. assert kp is not None
  1953. assert kp.cali_idx == 7
  1954. assert kp.k_value == pytest.approx(0.028)
  1955. assert kp.extruder == 0
  1956. assert kp.nozzle_diameter == "0.4"
  1957. assert kp.name == "Devil Design PLA"
  1958. @pytest.mark.asyncio
  1959. @pytest.mark.integration
  1960. async def test_no_assignment_no_persist(
  1961. self,
  1962. async_client: AsyncClient,
  1963. db_session,
  1964. printer_factory,
  1965. ):
  1966. """No SpoolAssignment AND no SpoolmanSlotAssignment → no DB write, MQTT still sent."""
  1967. from backend.app.models.spool_k_profile import SpoolKProfile
  1968. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  1969. printer = await printer_factory(model="H2D")
  1970. mock_client = MagicMock()
  1971. mock_client.ams_set_filament_setting.return_value = True
  1972. mock_client.extrusion_cali_sel.return_value = True
  1973. mock_client.request_status_update.return_value = True
  1974. mock_state = MagicMock()
  1975. mock_state.ams_extruder_map = {"0": 0}
  1976. mock_state.raw_data = {"ams": {"ams": []}}
  1977. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1978. mock_pm.get_client.return_value = mock_client
  1979. mock_pm.get_status.return_value = mock_state
  1980. response = await async_client.post(
  1981. f"/api/v1/printers/{printer.id}/slots/0/3/configure",
  1982. params={
  1983. "tray_info_idx": "GFL05",
  1984. "tray_type": "PLA",
  1985. "tray_sub_brands": "PLA Basic",
  1986. "tray_color": "FFFFFFFF",
  1987. "nozzle_temp_min": 190,
  1988. "nozzle_temp_max": 230,
  1989. "cali_idx": 5,
  1990. "k_value": 0.020,
  1991. },
  1992. )
  1993. assert response.status_code == 200
  1994. # MQTT sent (was successful), but no DB writes
  1995. mock_client.extrusion_cali_sel.assert_called_once()
  1996. local_count = (await db_session.execute(select(SpoolKProfile))).scalars().all()
  1997. sm_count = (await db_session.execute(select(SpoolmanKProfile))).scalars().all()
  1998. assert len(local_count) == 0
  1999. assert len(sm_count) == 0
  2000. @pytest.mark.asyncio
  2001. @pytest.mark.integration
  2002. async def test_negative_cali_idx_no_persist(
  2003. self,
  2004. async_client: AsyncClient,
  2005. db_session,
  2006. printer_factory,
  2007. ):
  2008. """cali_idx=-1 (no profile selected) → no DB write even when assignment exists."""
  2009. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  2010. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  2011. printer = await printer_factory(model="H2D")
  2012. db_session.add(
  2013. SpoolmanSlotAssignment(
  2014. printer_id=printer.id,
  2015. ams_id=0,
  2016. tray_id=3,
  2017. spoolman_spool_id=216,
  2018. )
  2019. )
  2020. await db_session.commit()
  2021. mock_client = MagicMock()
  2022. mock_client.ams_set_filament_setting.return_value = True
  2023. mock_client.extrusion_cali_sel.return_value = True
  2024. mock_client.extrusion_cali_set.return_value = True
  2025. mock_client.request_status_update.return_value = True
  2026. mock_state = MagicMock()
  2027. mock_state.ams_extruder_map = {"0": 0}
  2028. mock_state.raw_data = {"ams": {"ams": []}}
  2029. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  2030. mock_pm.get_client.return_value = mock_client
  2031. mock_pm.get_status.return_value = mock_state
  2032. response = await async_client.post(
  2033. f"/api/v1/printers/{printer.id}/slots/0/3/configure",
  2034. params={
  2035. "tray_info_idx": "GFL05",
  2036. "tray_type": "PLA",
  2037. "tray_sub_brands": "PLA Basic",
  2038. "tray_color": "FFFFFFFF",
  2039. "nozzle_temp_min": 190,
  2040. "nozzle_temp_max": 230,
  2041. "cali_idx": -1,
  2042. "k_value": 0.0,
  2043. },
  2044. )
  2045. assert response.status_code == 200
  2046. sm_kps = (
  2047. (await db_session.execute(select(SpoolmanKProfile).where(SpoolmanKProfile.spoolman_spool_id == 216)))
  2048. .scalars()
  2049. .all()
  2050. )
  2051. assert len(sm_kps) == 0 # cali_idx=-1 means "no profile" — don't write
  2052. @pytest.mark.asyncio
  2053. @pytest.mark.integration
  2054. async def test_zero_cali_idx_persists(
  2055. self,
  2056. async_client: AsyncClient,
  2057. db_session,
  2058. printer_factory,
  2059. ):
  2060. """cali_idx=0 is the first valid profile slot (NOT a sentinel for missing)."""
  2061. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  2062. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  2063. printer = await printer_factory(model="H2D")
  2064. db_session.add(
  2065. SpoolmanSlotAssignment(
  2066. printer_id=printer.id,
  2067. ams_id=0,
  2068. tray_id=3,
  2069. spoolman_spool_id=216,
  2070. )
  2071. )
  2072. await db_session.commit()
  2073. mock_client = MagicMock()
  2074. mock_client.ams_set_filament_setting.return_value = True
  2075. mock_client.extrusion_cali_sel.return_value = True
  2076. mock_client.request_status_update.return_value = True
  2077. mock_state = MagicMock()
  2078. mock_state.ams_extruder_map = {"0": 0}
  2079. mock_state.raw_data = {"ams": {"ams": []}}
  2080. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  2081. mock_pm.get_client.return_value = mock_client
  2082. mock_pm.get_status.return_value = mock_state
  2083. response = await async_client.post(
  2084. f"/api/v1/printers/{printer.id}/slots/0/3/configure",
  2085. params={
  2086. "tray_info_idx": "GFL05",
  2087. "tray_type": "PLA",
  2088. "tray_sub_brands": "PLA Basic",
  2089. "tray_color": "FFFFFFFF",
  2090. "nozzle_temp_min": 190,
  2091. "nozzle_temp_max": 230,
  2092. "cali_idx": 0,
  2093. "k_value": 0.020,
  2094. },
  2095. )
  2096. assert response.status_code == 200
  2097. kp = (
  2098. await db_session.execute(select(SpoolmanKProfile).where(SpoolmanKProfile.spoolman_spool_id == 216))
  2099. ).scalar_one_or_none()
  2100. assert kp is not None
  2101. assert kp.cali_idx == 0 # explicitly testing 0 is valid
  2102. @pytest.mark.asyncio
  2103. @pytest.mark.integration
  2104. async def test_upsert_idempotent(
  2105. self,
  2106. async_client: AsyncClient,
  2107. db_session,
  2108. printer_factory,
  2109. ):
  2110. """Repeated POSTs update the same row (UNIQUE on spool_id+printer+extruder+nozzle_diameter)."""
  2111. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  2112. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  2113. printer = await printer_factory(model="H2D")
  2114. db_session.add(
  2115. SpoolmanSlotAssignment(
  2116. printer_id=printer.id,
  2117. ams_id=0,
  2118. tray_id=3,
  2119. spoolman_spool_id=216,
  2120. )
  2121. )
  2122. await db_session.commit()
  2123. mock_client = MagicMock()
  2124. mock_client.ams_set_filament_setting.return_value = True
  2125. mock_client.extrusion_cali_sel.return_value = True
  2126. mock_client.request_status_update.return_value = True
  2127. mock_state = MagicMock()
  2128. mock_state.ams_extruder_map = {"0": 0}
  2129. mock_state.raw_data = {"ams": {"ams": []}}
  2130. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  2131. mock_pm.get_client.return_value = mock_client
  2132. mock_pm.get_status.return_value = mock_state
  2133. # First call with cali_idx=5
  2134. await async_client.post(
  2135. f"/api/v1/printers/{printer.id}/slots/0/3/configure",
  2136. params={
  2137. "tray_info_idx": "GFL05",
  2138. "tray_type": "PLA",
  2139. "tray_sub_brands": "PLA Basic",
  2140. "tray_color": "FFFFFFFF",
  2141. "nozzle_temp_min": 190,
  2142. "nozzle_temp_max": 230,
  2143. "cali_idx": 5,
  2144. "k_value": 0.020,
  2145. },
  2146. )
  2147. # Second call with cali_idx=10 (same slot/spool/extruder/nozzle)
  2148. await async_client.post(
  2149. f"/api/v1/printers/{printer.id}/slots/0/3/configure",
  2150. params={
  2151. "tray_info_idx": "GFL05",
  2152. "tray_type": "PLA",
  2153. "tray_sub_brands": "PLA Matte",
  2154. "tray_color": "FFFFFFFF",
  2155. "nozzle_temp_min": 190,
  2156. "nozzle_temp_max": 230,
  2157. "cali_idx": 10,
  2158. "k_value": 0.025,
  2159. },
  2160. )
  2161. # Should be exactly ONE row (updated), not two
  2162. kps = (
  2163. (await db_session.execute(select(SpoolmanKProfile).where(SpoolmanKProfile.spoolman_spool_id == 216)))
  2164. .scalars()
  2165. .all()
  2166. )
  2167. assert len(kps) == 1
  2168. assert kps[0].cali_idx == 10 # updated to most recent
  2169. assert kps[0].name == "PLA Matte"
  2170. @pytest.mark.asyncio
  2171. @pytest.mark.integration
  2172. async def test_external_slot_extruder_inversion(
  2173. self,
  2174. async_client: AsyncClient,
  2175. db_session,
  2176. printer_factory,
  2177. ):
  2178. """ams_id=255 + tray_id=0 → kp.extruder=1 (ext-L); tray_id=1 → extruder=0 (ext-R)."""
  2179. from backend.app.models.spool import Spool
  2180. from backend.app.models.spool_assignment import SpoolAssignment
  2181. from backend.app.models.spool_k_profile import SpoolKProfile
  2182. printer = await printer_factory(model="H2D")
  2183. spool = Spool(material="PLA")
  2184. db_session.add(spool)
  2185. await db_session.flush()
  2186. # Note: SpoolmanSlotAssignment can't store ams_id=255 with tray_id=1
  2187. # under the ck_tray_id_range constraint (0-3 valid). External-slot
  2188. # K-profile persistence is therefore tested via local SpoolAssignment.
  2189. db_session.add(
  2190. SpoolAssignment(
  2191. spool_id=spool.id,
  2192. printer_id=printer.id,
  2193. ams_id=255,
  2194. tray_id=0,
  2195. )
  2196. )
  2197. await db_session.commit()
  2198. spool_id = spool.id
  2199. mock_client = MagicMock()
  2200. mock_client.ams_set_filament_setting.return_value = True
  2201. mock_client.extrusion_cali_sel.return_value = True
  2202. mock_client.request_status_update.return_value = True
  2203. mock_state = MagicMock()
  2204. mock_state.ams_extruder_map = {"0": 0} # truthy so external-inversion path runs
  2205. mock_state.raw_data = {"ams": {"ams": []}}
  2206. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  2207. mock_pm.get_client.return_value = mock_client
  2208. mock_pm.get_status.return_value = mock_state
  2209. response = await async_client.post(
  2210. f"/api/v1/printers/{printer.id}/slots/255/0/configure",
  2211. params={
  2212. "tray_info_idx": "GFL05",
  2213. "tray_type": "PLA",
  2214. "tray_sub_brands": "PLA Basic",
  2215. "tray_color": "FFFFFFFF",
  2216. "nozzle_temp_min": 190,
  2217. "nozzle_temp_max": 230,
  2218. "cali_idx": 5,
  2219. "k_value": 0.020,
  2220. },
  2221. )
  2222. assert response.status_code == 200
  2223. kp = (
  2224. await db_session.execute(select(SpoolKProfile).where(SpoolKProfile.spool_id == spool_id))
  2225. ).scalar_one_or_none()
  2226. assert kp is not None
  2227. # tray_id=0 → extruder = 1 - 0 = 1
  2228. assert kp.extruder == 1
  2229. @pytest.mark.asyncio
  2230. @pytest.mark.integration
  2231. async def test_dual_nozzle_extruder_persists(
  2232. self,
  2233. async_client: AsyncClient,
  2234. db_session,
  2235. printer_factory,
  2236. ):
  2237. """ams_extruder_map with extruder=1 → kp.extruder=1 persisted correctly."""
  2238. from backend.app.models.spool import Spool
  2239. from backend.app.models.spool_assignment import SpoolAssignment
  2240. from backend.app.models.spool_k_profile import SpoolKProfile
  2241. printer = await printer_factory(model="H2D")
  2242. spool = Spool(material="PLA")
  2243. db_session.add(spool)
  2244. await db_session.flush()
  2245. db_session.add(
  2246. SpoolAssignment(
  2247. spool_id=spool.id,
  2248. printer_id=printer.id,
  2249. ams_id=2,
  2250. tray_id=3,
  2251. )
  2252. )
  2253. await db_session.commit()
  2254. spool_id = spool.id
  2255. mock_client = MagicMock()
  2256. mock_client.ams_set_filament_setting.return_value = True
  2257. mock_client.extrusion_cali_sel.return_value = True
  2258. mock_client.request_status_update.return_value = True
  2259. mock_state = MagicMock()
  2260. mock_state.ams_extruder_map = {"2": 1} # AMS 2 is on extruder 1
  2261. mock_state.raw_data = {"ams": {"ams": []}}
  2262. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  2263. mock_pm.get_client.return_value = mock_client
  2264. mock_pm.get_status.return_value = mock_state
  2265. response = await async_client.post(
  2266. f"/api/v1/printers/{printer.id}/slots/2/3/configure",
  2267. params={
  2268. "tray_info_idx": "GFL05",
  2269. "tray_type": "PLA",
  2270. "tray_sub_brands": "PLA Basic",
  2271. "tray_color": "FFFFFFFF",
  2272. "nozzle_temp_min": 190,
  2273. "nozzle_temp_max": 230,
  2274. "cali_idx": 5,
  2275. "k_value": 0.020,
  2276. },
  2277. )
  2278. assert response.status_code == 200
  2279. kp = (
  2280. await db_session.execute(select(SpoolKProfile).where(SpoolKProfile.spool_id == spool_id))
  2281. ).scalar_one_or_none()
  2282. assert kp is not None
  2283. assert kp.extruder == 1
  2284. @pytest.mark.asyncio
  2285. @pytest.mark.integration
  2286. async def test_db_error_does_not_fail_endpoint(
  2287. self,
  2288. async_client: AsyncClient,
  2289. db_session,
  2290. printer_factory,
  2291. ):
  2292. """DB errors during K-profile persistence are best-effort — endpoint still returns 200.
  2293. Verifies the try/except wrap added in P13-3b: if DB upsert fails (e.g.
  2294. because the schema is out of sync, a constraint violation, or any
  2295. other transient error), the MQTT command was already sent successfully
  2296. so we shouldn't return 500 to the user. The error is logged and the
  2297. endpoint returns success.
  2298. """
  2299. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  2300. printer = await printer_factory(model="H2D")
  2301. db_session.add(
  2302. SpoolmanSlotAssignment(
  2303. printer_id=printer.id,
  2304. ams_id=0,
  2305. tray_id=3,
  2306. spoolman_spool_id=216,
  2307. )
  2308. )
  2309. await db_session.commit()
  2310. mock_client = MagicMock()
  2311. mock_client.ams_set_filament_setting.return_value = True
  2312. mock_client.extrusion_cali_sel.return_value = True
  2313. mock_client.request_status_update.return_value = True
  2314. mock_state = MagicMock()
  2315. mock_state.ams_extruder_map = {"0": 0}
  2316. mock_state.raw_data = {"ams": {"ams": []}}
  2317. # Force the K-profile persistence path to fail by patching the
  2318. # SpoolmanKProfile model class with a sentinel that raises when
  2319. # instantiated. The MQTT call has already happened by then, so the
  2320. # endpoint must catch and log without returning 500.
  2321. with (
  2322. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  2323. patch(
  2324. "backend.app.models.spoolman_k_profile.SpoolmanKProfile",
  2325. side_effect=RuntimeError("Simulated DB error"),
  2326. ),
  2327. ):
  2328. mock_pm.get_client.return_value = mock_client
  2329. mock_pm.get_status.return_value = mock_state
  2330. response = await async_client.post(
  2331. f"/api/v1/printers/{printer.id}/slots/0/3/configure",
  2332. params={
  2333. "tray_info_idx": "GFL05",
  2334. "tray_type": "PLA",
  2335. "tray_sub_brands": "PLA Basic",
  2336. "tray_color": "FFFFFFFF",
  2337. "nozzle_temp_min": 190,
  2338. "nozzle_temp_max": 230,
  2339. "cali_idx": 5,
  2340. "k_value": 0.020,
  2341. },
  2342. )
  2343. # Endpoint returns success — MQTT was sent, K-profile failed silently
  2344. assert response.status_code == 200
  2345. # MQTT was indeed called
  2346. mock_client.extrusion_cali_sel.assert_called_once()